Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.requestMatchers(
"/api/v1/users/signup",
"/api/v1/users/login",
"/api/v1/users/reissue",
"/api/v1/users/logout",
Comment on lines +39 to +40

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

로그아웃 엔드포인트의 보안 설정 재검토 필요

/api/v1/users/logout 엔드포인트를 인증 없이 접근 가능하도록 설정하는 것은 보안상 위험할 수 있습니다. 로그아웃은 인증된 사용자만 수행해야 하는 작업입니다.

제안사항:

  • reissue 엔드포인트는 리프레시 토큰 검증이 필요하므로 permitAll() 유지 가능
  • logout 엔드포인트는 액세스 토큰 검증이 필요하므로 authenticated() 요구 권장
                        .requestMatchers(
                                "/api/v1/users/signup",
                                "/api/v1/users/login",
                                "/api/v1/users/reissue",
-                               "/api/v1/users/logout",
                                "/swagger-ui/**",
                                "/v3/api-docs/**"
                        ).permitAll()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"/api/v1/users/reissue",
"/api/v1/users/logout",
http
.authorizeRequests()
.requestMatchers(
"/api/v1/users/signup",
"/api/v1/users/login",
"/api/v1/users/reissue",
"/swagger-ui/**",
"/v3/api-docs/**"
).permitAll()
// all other endpoints (including /api/v1/users/logout) now require authentication
.anyRequest().authenticated();
🤖 Prompt for AI Agents
In src/main/java/com/wayble/server/common/config/SecurityConfig.java around
lines 39 to 40, the /api/v1/users/logout endpoint is currently configured to
allow access without authentication, which is a security risk. Modify the
security configuration to require authentication for the logout endpoint by
replacing permitAll() with authenticated() for this path, while keeping
permitAll() for the /api/v1/users/reissue endpoint as it requires refresh token
validation.

"/swagger-ui/**",
"/v3/api-docs/**"
).permitAll()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ public class JwtProperties {

private String secret;
private long accessExp;
private long refreshExp;


public String getSecret() {
Expand All @@ -26,4 +27,8 @@ public void setSecret(String secret) {
public void setAccessExp(long accessExp) {
this.accessExp = accessExp;
}

public long getRefreshExp() { return refreshExp; }

public void setRefreshExp(long refreshExp) { this.refreshExp = refreshExp; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ public String generateToken(Long userId, String role) {
.compact();
}

public String generateRefreshToken(Long userId) {
return Jwts.builder()
.setSubject(String.valueOf(userId))
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + jwtProperties.getRefreshExp()))
.signWith(signingKey, SignatureAlgorithm.HS256)
.compact();
}

public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(signingKey).build().parseClaimsJws(token);
Expand All @@ -53,4 +62,12 @@ public Long getUserId(String token) {
.getSubject();
return Long.parseLong(subject);
}

public Long getTokenExpiry(String token) {
if (!validateToken(token)) {
throw new IllegalArgumentException("Invalid token");
}
return Jwts.parserBuilder().setSigningKey(signingKey).build()
.parseClaimsJws(token).getBody().getExpiration().getTime();
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package com.wayble.server.user.controller;

import com.wayble.server.common.config.security.jwt.JwtTokenProvider;
import com.wayble.server.common.exception.ApplicationException;
import com.wayble.server.common.response.CommonResponse;
import com.wayble.server.user.dto.UserLoginRequestDto;
import com.wayble.server.user.dto.UserRegisterRequestDto;
import com.wayble.server.user.dto.token.TokenResponseDto;
import com.wayble.server.user.exception.UserErrorCase;
import com.wayble.server.user.service.UserService;
import com.wayble.server.user.service.auth.AuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
Expand All @@ -24,6 +28,7 @@ public class UserController {

private final UserService userService;
private final AuthService authService;
private final JwtTokenProvider jwtProvider;

@PostMapping("/signup")
@Operation(
Expand Down Expand Up @@ -57,4 +62,54 @@ public CommonResponse<TokenResponseDto> login(@RequestBody @Valid UserLoginReque
return CommonResponse.success(tokenDto);
}

@PostMapping("/reissue")
@Operation(
summary = "AccessToken 재발급",
description = "클라이언트의 accessToken 만료 시, 유효한 refreshToken으로 새로운 accessToken 및 refreshToken을 재발급합니다."


)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "토큰 재발급 성공",
content = @Content(schema = @Schema(implementation = com.wayble.server.user.dto.token.TokenResponseDto.class))),
@ApiResponse(responseCode = "400", description = "refreshToken이 유효하지 않음",
content = @Content(schema = @Schema(implementation = com.wayble.server.common.response.CommonResponse.class)))
})
public CommonResponse<TokenResponseDto> reissue(
@Parameter(description = "재발급용 refreshToken", required = true)
@RequestParam String refreshToken
) {
TokenResponseDto tokens = authService.reissue(refreshToken);
return CommonResponse.success(tokens);
}

@PostMapping("/logout")
@Operation(
summary = "유저 로그아웃",
description = "로그아웃 처리(서버에 저장된 refreshToken을 삭제합니다)."
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "로그아웃 성공"),
@ApiResponse(responseCode = "401", description = "유효하지 않은 accessToken")
})
public CommonResponse<String> logout(
@Parameter(
description = "사용자 인증용 accessToken (Bearer {token} 형태)",
required = true,
example = "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
)
@RequestHeader("Authorization") String accessToken
) {
if (accessToken == null || !accessToken.startsWith("Bearer ")) {
throw new ApplicationException(UserErrorCase.INVALID_CREDENTIALS);
}
String token = accessToken.replace("Bearer ", "");
if (token.isEmpty()) {
throw new ApplicationException(UserErrorCase.INVALID_CREDENTIALS);
}
Long userId = jwtProvider.getUserId(token);
authService.logout(userId);
return CommonResponse.success("로그아웃에 성공하였습니다.");
}

}
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
package com.wayble.server.user.dto.token;

public record TokenResponseDto(String accessToken) {}
public record TokenResponseDto(String accessToken, String refreshToken) {}
34 changes: 34 additions & 0 deletions src/main/java/com/wayble/server/user/entity/RefreshToken.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.wayble.server.user.entity;

import jakarta.persistence.*;
import lombok.*;

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@Table(name = "refresh_token")
public class RefreshToken {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false, unique = true) // 1인 1토큰
private Long userId;

@Column(nullable = false)
private String token;

@Column(nullable = false)
private Long expiry; // 만료 시간

public void setToken(String token) {
this.token = token;
}

public void setExpiry(Long expiry) {
this.expiry = expiry;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.wayble.server.user.repository;

import com.wayble.server.user.entity.RefreshToken;
import jakarta.transaction.Transactional;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByUserId(Long userId);
Optional<RefreshToken> findByToken(String token);

@Transactional
void deleteByUserId(Long userId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

삭제 메서드에 @transactional 추가 권장

deleteByUserId 메서드는 데이터를 삭제하는 작업이므로 @transactional 어노테이션을 추가하는 것이 좋습니다.

+import org.springframework.transaction.annotation.Transactional;
+
+@Transactional
 void deleteByUserId(Long userId);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
void deleteByUserId(Long userId);
import org.springframework.transaction.annotation.Transactional;
@Transactional
void deleteByUserId(Long userId);
🤖 Prompt for AI Agents
In src/main/java/com/wayble/server/user/repository/RefreshTokenRepository.java
at line 11, the deleteByUserId method performs a data deletion operation and
should be annotated with @Transactional to ensure the operation is executed
within a transaction. Add the @Transactional annotation above the deleteByUserId
method declaration to properly manage the transaction scope during deletion.

}
56 changes: 54 additions & 2 deletions src/main/java/com/wayble/server/user/service/auth/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
import com.wayble.server.common.exception.ApplicationException;
import com.wayble.server.user.dto.UserLoginRequestDto;
import com.wayble.server.user.dto.token.TokenResponseDto;
import com.wayble.server.user.entity.RefreshToken;
import com.wayble.server.user.entity.User;
import com.wayble.server.user.exception.UserErrorCase;
import com.wayble.server.user.repository.RefreshTokenRepository;
import com.wayble.server.user.repository.UserRepository;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
Expand All @@ -16,6 +19,7 @@
public class AuthService {

private final UserRepository userRepository;
private final RefreshTokenRepository refreshTokenRepository;
private final PasswordEncoder encoder;
private final JwtTokenProvider jwtProvider;

Expand All @@ -25,7 +29,55 @@ public TokenResponseDto login(UserLoginRequestDto req) {
if (!encoder.matches(req.password(), user.getPassword())) {
throw new ApplicationException(UserErrorCase.INVALID_CREDENTIALS);
}
String token = jwtProvider.generateToken(user.getId(), user.getUserType().name());
return new TokenResponseDto(token);
String accessToken = jwtProvider.generateToken(user.getId(), user.getUserType().name());
String refreshToken = jwtProvider.generateRefreshToken(user.getId());
Long expiry = jwtProvider.getTokenExpiry(refreshToken);

refreshTokenRepository.deleteByUserId(user.getId());

RefreshToken entity = RefreshToken.builder()
.userId(user.getId())
.token(refreshToken)
.expiry(expiry)
.build();
refreshTokenRepository.save(entity);

return new TokenResponseDto(accessToken, refreshToken);
}

public TokenResponseDto reissue(String refreshToken) {
if (!jwtProvider.validateToken(refreshToken)) {
throw new ApplicationException(UserErrorCase.INVALID_CREDENTIALS);
}
Long userId = jwtProvider.getUserId(refreshToken);
RefreshToken saved = refreshTokenRepository.findByUserId(userId)
.orElseThrow(() -> new ApplicationException(UserErrorCase.INVALID_CREDENTIALS));

if (!saved.getToken().equals(refreshToken)) {
throw new ApplicationException(UserErrorCase.INVALID_CREDENTIALS);
}

if (saved.getExpiry() < System.currentTimeMillis()) {
refreshTokenRepository.delete(saved);
throw new ApplicationException(UserErrorCase.INVALID_CREDENTIALS);
}

User user = userRepository.findById(userId)
.orElseThrow(() -> new ApplicationException(UserErrorCase.USER_NOT_FOUND));

String newRefreshToken = jwtProvider.generateRefreshToken(userId);
Long newExpiry = jwtProvider.getTokenExpiry(newRefreshToken);

saved.setToken(newRefreshToken);
saved.setExpiry(newExpiry);
refreshTokenRepository.save(saved);

String newAccessToken = jwtProvider.generateToken(userId, user.getUserType().name());
return new TokenResponseDto(newAccessToken, newRefreshToken);
}

@Transactional
public void logout(Long userId) {
refreshTokenRepository.deleteByUserId(userId);
}
}
Loading