Skip to content

Commit a2b7d40

Browse files
committed
Merge remote-tracking branch 'origin/CLAP-84' into CLAP-84
2 parents 4e37298 + 6799789 commit a2b7d40

29 files changed

+466
-48
lines changed

src/main/java/clap/server/adapter/inbound/security/filter/JwtAuthenticationFilter.java

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
package clap.server.adapter.inbound.security.filter;
22

33
import clap.server.adapter.outbound.jwt.JwtClaims;
4-
import clap.server.application.port.outbound.auth.JwtProvider;
54
import clap.server.adapter.outbound.jwt.access.AccessTokenClaimKeys;
5+
import clap.server.application.port.outbound.auth.JwtProvider;
66
import clap.server.exception.JwtException;
77
import clap.server.exception.code.AuthErrorCode;
8+
import io.jsonwebtoken.Claims;
89
import jakarta.servlet.FilterChain;
910
import jakarta.servlet.ServletException;
1011
import jakarta.servlet.http.HttpServletRequest;
@@ -31,8 +32,10 @@
3132
@Component
3233
@RequiredArgsConstructor
3334
public class JwtAuthenticationFilter extends OncePerRequestFilter {
35+
private static final String TEMPORARY_TOKEN_ALLOWED_ENDPOINT = "/api/members/initial-password";
3436
private final UserDetailsService securityUserDetailsService;
3537
private final JwtProvider accessTokenProvider;
38+
private final JwtProvider temporaryTokenProvider;
3639
private final AccessDeniedHandler accessDeniedHandler;
3740

3841
@Override
@@ -48,6 +51,7 @@ protected void doFilterInternal(
4851
}
4952

5053
String accessToken = resolveAccessToken(request);
54+
5155
UserDetails userDetails = getUserDetails(accessToken);
5256
authenticateUser(userDetails, request);
5357
} catch (AccessDeniedException e) {
@@ -73,9 +77,20 @@ private String resolveAccessToken(
7377
handleAuthException(AuthErrorCode.EMPTY_ACCESS_KEY);
7478
}
7579

80+
String requestUrl = request.getRequestURI();
81+
boolean isTemporaryToken = isTemporaryToken(token);
82+
JwtProvider tokenProvider = isTemporaryToken ? temporaryTokenProvider : accessTokenProvider;
83+
84+
log.info("Token is Temporary {}", isTemporaryToken);
85+
86+
if (isTemporaryTokenAllowed(requestUrl) != isTemporaryToken) {
87+
log.error("FORBIDDEN_TEMPORARY_TOKEN_ACCESS");
88+
handleAuthException(AuthErrorCode.FORBIDDEN_ACCESS_TOKEN);
89+
}
90+
7691
// TODO: 블랙리스트 토큰 처리 로직 추가 필요
7792

78-
if (accessTokenProvider.isTokenExpired(token)) {
93+
if (tokenProvider.isTokenExpired(token)) {
7994
log.error("EXPIRED_TOKEN");
8095
handleAuthException(AuthErrorCode.EXPIRED_TOKEN);
8196
}
@@ -84,9 +99,23 @@ private String resolveAccessToken(
8499
}
85100

86101

102+
private boolean isTemporaryTokenAllowed(String requestUrl) {
103+
return requestUrl.equals(TEMPORARY_TOKEN_ALLOWED_ENDPOINT);
104+
}
105+
106+
private boolean isTemporaryToken(String token) {
107+
try {
108+
Claims claims = temporaryTokenProvider.getClaimsFromToken(token);
109+
return claims.get("isTemporary", Boolean.class) != null && claims.get("isTemporary", Boolean.class);
110+
} catch (Exception e) {
111+
return false;
112+
}
113+
}
114+
87115
private UserDetails getUserDetails(String accessToken) {
88-
JwtClaims claims = accessTokenProvider.parseJwtClaimsFromToken(accessToken);
89-
String memberId = (String)claims.getClaims().get(AccessTokenClaimKeys.USER_ID.getValue());
116+
JwtProvider tokenProvider = isTemporaryToken(accessToken) ? temporaryTokenProvider : accessTokenProvider;
117+
JwtClaims claims = tokenProvider.parseJwtClaimsFromToken(accessToken);
118+
String memberId = (String) claims.getClaims().get(AccessTokenClaimKeys.USER_ID.getValue());
90119
return securityUserDetailsService.loadUserByUsername(memberId);
91120
}
92121

src/main/java/clap/server/adapter/inbound/web/admin/RegisterMemberController.java

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,27 @@
11
package clap.server.adapter.inbound.web.admin;
22

3+
import clap.server.adapter.inbound.security.SecurityUserDetails;
34
import clap.server.adapter.inbound.web.dto.admin.RegisterMemberRequest;
45
import clap.server.application.port.inbound.management.RegisterMemberUsecase;
56
import clap.server.common.annotation.architecture.WebAdapter;
6-
import clap.server.adapter.inbound.security.SecurityUserDetails;
7-
import com.fasterxml.jackson.core.JsonProcessingException;
7+
import io.swagger.v3.oas.annotations.Operation;
8+
import io.swagger.v3.oas.annotations.tags.Tag;
89
import jakarta.validation.Valid;
910
import lombok.RequiredArgsConstructor;
1011
import org.springframework.security.access.annotation.Secured;
1112
import org.springframework.security.core.annotation.AuthenticationPrincipal;
12-
import org.springframework.web.bind.annotation.*;
13+
import org.springframework.web.bind.annotation.PostMapping;
14+
import org.springframework.web.bind.annotation.RequestBody;
15+
import org.springframework.web.bind.annotation.RequestMapping;
1316

17+
@Tag(name = "회원 관리 - 등록")
1418
@WebAdapter
1519
@RequiredArgsConstructor
1620
@RequestMapping("/api/managements")
1721
public class RegisterMemberController {
1822
private final RegisterMemberUsecase registerMemberUsecase;
1923

24+
@Operation(summary = "단일 회원 등록 API")
2025
@PostMapping("/members")
2126
@Secured("ROLE_ADMIN")
2227
public void registerMember(@AuthenticationPrincipal SecurityUserDetails userInfo,

src/main/java/clap/server/adapter/inbound/web/auth/AuthController.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,21 @@
55
import clap.server.application.port.inbound.auth.AuthUsecase;
66
import clap.server.common.annotation.architecture.WebAdapter;
77
import io.swagger.v3.oas.annotations.Operation;
8+
import io.swagger.v3.oas.annotations.tags.Tag;
89
import lombok.RequiredArgsConstructor;
9-
import lombok.extern.slf4j.Slf4j;
1010
import org.springframework.http.ResponseEntity;
1111
import org.springframework.web.bind.annotation.PostMapping;
1212
import org.springframework.web.bind.annotation.RequestBody;
1313
import org.springframework.web.bind.annotation.RequestMapping;
1414

15+
@Tag(name = "로그인 / 로그아웃 / 토큰 재발급")
1516
@WebAdapter
1617
@RequiredArgsConstructor
1718
@RequestMapping("/api/auths")
18-
@Slf4j
1919
public class AuthController {
2020
private final AuthUsecase authUsecase;
2121

22-
@Operation(description = "로그인 API")
22+
@Operation(summary = "로그인 API")
2323
@PostMapping("/login")
2424
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) {
2525
return ResponseEntity.ok(authUsecase.login(request.nickname(), request.password()));

src/main/java/clap/server/adapter/inbound/web/dto/admin/RegisterMemberRequest.java

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,30 @@
11
package clap.server.adapter.inbound.web.dto.admin;
22

33
import clap.server.adapter.outbound.persistense.entity.member.constant.MemberRole;
4+
import io.swagger.v3.oas.annotations.media.Schema;
45
import jakarta.validation.constraints.NotBlank;
56
import jakarta.validation.constraints.NotNull;
7+
import jakarta.validation.constraints.Pattern;
68

79
public record RegisterMemberRequest(
8-
@NotBlank
10+
@NotBlank @Schema(description = "회원 이름")
911
String name,
1012
@NotBlank
13+
@Pattern(regexp = "^[a-zA-Z0-9_+&*-]+(?:\\.[a-zA-Z0-9_+&*-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,7}$",
14+
message = "올바른 이메일 형식이 아닙니다.")
15+
@Schema(description = "회원 이메일")
1116
String email,
12-
@NotBlank
17+
@NotBlank @Schema(description = "회원 닉네임, 로그인할 때 쓰입니다.")
18+
@Pattern(regexp = "^[a-z]{3,10}\\.[a-z]{1,5}$",
19+
message = "올바른 닉네임 형식이 아닙니다.")
1320
String nickname,
14-
@NotNull
21+
@NotNull @Schema(description = "승인 권한 여부")
1522
Boolean isReviewer,
16-
@NotNull
23+
@NotNull @Schema(description = "부서 ID")
1724
Long departmentId,
18-
@NotNull
25+
@NotNull @Schema(description = "회원 역할")
1926
MemberRole role,
20-
@NotBlank
27+
@NotBlank @Schema(description = "회원 직책")
2128
String departmentRole
2229
) {
2330
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package clap.server.adapter.inbound.web.member;
2+
3+
import clap.server.adapter.inbound.security.SecurityUserDetails;
4+
import clap.server.application.port.inbound.auth.ResetPasswordUsecase;
5+
import clap.server.common.annotation.architecture.WebAdapter;
6+
import clap.server.common.annotation.validation.password.ValidPassword;
7+
import io.swagger.v3.oas.annotations.Operation;
8+
import io.swagger.v3.oas.annotations.tags.Tag;
9+
import jakarta.validation.constraints.NotBlank;
10+
import lombok.RequiredArgsConstructor;
11+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
12+
import org.springframework.web.bind.annotation.PatchMapping;
13+
import org.springframework.web.bind.annotation.RequestBody;
14+
import org.springframework.web.bind.annotation.RequestMapping;
15+
16+
@Tag(name = "비밀번호 재설정")
17+
@WebAdapter
18+
@RequiredArgsConstructor
19+
@RequestMapping("/api")
20+
public class ResetPasswordController {
21+
private final ResetPasswordUsecase resetPasswordUsecase;
22+
23+
@Operation(summary = "초기 로그인 후 비밀번호 재설정 API", description = "swagger에서 따옴표를 포함하지 않고 요청합니다.")
24+
@PatchMapping("/members/initial-password")
25+
public void resetPasswordAndActivateMember(@AuthenticationPrincipal SecurityUserDetails userInfo,
26+
@RequestBody @NotBlank @ValidPassword String password) {
27+
resetPasswordUsecase.resetPasswordAndActivateMember(userInfo.getUserId(), password);
28+
}
29+
30+
@Operation(summary = "비밀번호 재설정 API", description = "swagger에서 따옴표를 포함하지 않고 요청합니다.")
31+
@PatchMapping("/members/password")
32+
public void resetPassword(@AuthenticationPrincipal SecurityUserDetails userInfo,
33+
@RequestBody @NotBlank @ValidPassword String password) {
34+
resetPasswordUsecase.resetPassword(userInfo.getUserId(), password);
35+
}
36+
37+
}

src/main/java/clap/server/adapter/outbound/jwt/access/AccessTokenProvider.java

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,15 @@
22

33
import clap.server.adapter.outbound.jwt.JwtClaims;
44
import clap.server.application.port.outbound.auth.JwtProvider;
5-
import clap.server.exception.JwtException;
65
import clap.server.common.annotation.jwt.AccessTokenStrategy;
76
import clap.server.common.utils.DateUtil;
7+
import clap.server.exception.JwtException;
88
import clap.server.exception.code.AuthErrorCode;
99
import io.jsonwebtoken.Claims;
1010
import io.jsonwebtoken.Jwts;
1111
import io.jsonwebtoken.security.Keys;
1212
import lombok.extern.slf4j.Slf4j;
1313
import org.springframework.beans.factory.annotation.Value;
14-
import org.springframework.context.annotation.Primary;
1514
import org.springframework.stereotype.Component;
1615

1716
import javax.crypto.SecretKey;
@@ -24,7 +23,6 @@
2423
import static clap.server.adapter.outbound.jwt.access.AccessTokenClaimKeys.USER_ID;
2524

2625
@Slf4j
27-
@Primary
2826
@Component
2927
@AccessTokenStrategy
3028
public class AccessTokenProvider implements JwtProvider {
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package clap.server.adapter.outbound.jwt.access.temporary;
2+
3+
import clap.server.adapter.outbound.jwt.JwtClaims;
4+
import clap.server.adapter.outbound.jwt.access.AccessTokenClaimKeys;
5+
import lombok.AccessLevel;
6+
import lombok.RequiredArgsConstructor;
7+
8+
import java.util.HashMap;
9+
import java.util.Map;
10+
11+
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
12+
public class TemporaryTokenClaim implements JwtClaims {
13+
private final Map<String, Object> claims;
14+
15+
public static TemporaryTokenClaim of(Long memberId) {
16+
Map<String, Object> claims = new HashMap<>();
17+
claims.put(AccessTokenClaimKeys.USER_ID.getValue(), memberId.toString());
18+
claims.put(TemporaryTokenClaimKeys.IS_TEMPORARY.getValue(), true);
19+
return new TemporaryTokenClaim(claims);
20+
}
21+
22+
@Override
23+
public Map<String, Object> getClaims() {
24+
return claims;
25+
}
26+
27+
public Long getMemberId() {
28+
return Long.parseLong((String) claims.get(AccessTokenClaimKeys.USER_ID.getValue()));
29+
}
30+
31+
public boolean isTemporary() {
32+
return (boolean) claims.get(TemporaryTokenClaimKeys.IS_TEMPORARY.getValue());
33+
}
34+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package clap.server.adapter.outbound.jwt.access.temporary;
2+
3+
import lombok.Getter;
4+
5+
@Getter
6+
public enum TemporaryTokenClaimKeys {
7+
IS_TEMPORARY("isTemporary");
8+
9+
private final String value;
10+
11+
TemporaryTokenClaimKeys(String value) {
12+
this.value = value;
13+
}
14+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package clap.server.adapter.outbound.jwt.access.temporary;
2+
3+
import clap.server.adapter.outbound.jwt.JwtClaims;
4+
import clap.server.application.port.outbound.auth.JwtProvider;
5+
import clap.server.common.annotation.jwt.TemporaryTokenStrategy;
6+
import clap.server.common.utils.DateUtil;
7+
import clap.server.exception.JwtException;
8+
import clap.server.exception.code.AuthErrorCode;
9+
import io.jsonwebtoken.Claims;
10+
import io.jsonwebtoken.Jwts;
11+
import io.jsonwebtoken.security.Keys;
12+
import lombok.extern.slf4j.Slf4j;
13+
import org.springframework.beans.factory.annotation.Value;
14+
import org.springframework.stereotype.Component;
15+
16+
import javax.crypto.SecretKey;
17+
import java.time.Duration;
18+
import java.time.LocalDateTime;
19+
import java.util.Base64;
20+
import java.util.Date;
21+
import java.util.Map;
22+
23+
import static clap.server.adapter.outbound.jwt.access.AccessTokenClaimKeys.USER_ID;
24+
25+
@Slf4j
26+
@Component
27+
@TemporaryTokenStrategy
28+
public class TemporaryTokenProvider implements JwtProvider {
29+
private final SecretKey secretKey;
30+
private final Duration tokenExpiration;
31+
32+
public TemporaryTokenProvider(
33+
@Value("${jwt.secret-key.temporary-token}") String jwtSecretKey,
34+
@Value("${jwt.expiration-time.temporary-token}") Duration tokenExpiration
35+
) {
36+
byte[] keyBytes = Base64.getDecoder().decode(jwtSecretKey);
37+
this.secretKey = Keys.hmacShaKeyFor(keyBytes);
38+
this.tokenExpiration = tokenExpiration;
39+
}
40+
41+
@Override
42+
public String createToken(JwtClaims claims) {
43+
Date now = new Date();
44+
45+
return Jwts.builder()
46+
.setHeader(createHeader())
47+
.setClaims(claims.getClaims())
48+
.signWith(secretKey)
49+
.setExpiration(createExpirationDate(now, tokenExpiration.toMillis()))
50+
.compact();
51+
}
52+
53+
@Override
54+
public JwtClaims parseJwtClaimsFromToken(String token) {
55+
Claims claims = getClaimsFromToken(token);
56+
return TemporaryTokenClaim.of(
57+
Long.parseLong(claims.get(USER_ID.getValue(), String.class))
58+
);
59+
}
60+
61+
@Override
62+
public LocalDateTime getExpiredDate(String token) {
63+
Claims claims = getClaimsFromToken(token);
64+
return DateUtil.toLocalDateTime(claims.getExpiration());
65+
}
66+
67+
@Override
68+
public boolean isTokenExpired(String token) {
69+
try {
70+
Claims claims = getClaimsFromToken(token);
71+
return claims.getExpiration().before(new Date());
72+
} catch (Exception e) {
73+
log.error("Token is expired: {}", e.getMessage());
74+
throw new JwtException(AuthErrorCode.EMPTY_ACCESS_KEY);
75+
}
76+
}
77+
78+
@Override
79+
public Claims getClaimsFromToken(String token) {
80+
try {
81+
return Jwts.parserBuilder()
82+
.setSigningKey(secretKey)
83+
.build()
84+
.parseClaimsJws(token)
85+
.getBody();
86+
} catch (Exception e) {
87+
log.warn("Token is invalid: {}", e.getMessage());
88+
throw new JwtException(AuthErrorCode.INVALID_TOKEN);
89+
}
90+
}
91+
92+
private Map<String, Object> createHeader() {
93+
return Map.of(
94+
"typ", "JWT",
95+
"alg", "HS256",
96+
"regDate", System.currentTimeMillis()
97+
);
98+
}
99+
100+
private Date createExpirationDate(Date now, long expirationTime) {
101+
return new Date(now.getTime() + expirationTime);
102+
}
103+
}

0 commit comments

Comments
 (0)