Skip to content

Commit 3b633be

Browse files
authored
MOSU-3 feat: OAuth2 로그인 기능 구현 (#22)
* MOSU-3 feat: security.yml 추가 * MOSU-3 feat: redis 설정 추가 * MOSU-3 fix: User id 타입 및 Role 변경 * MOSU-3 feat: OAuth User Domain 구현 * MOSU-3 feat: Token 구현 * MOSU-3 feat: OAuth main 기능 구현 * MOSU-3 feat: OAuth 설정 구현 * MOSU-3 feat: TokenResponse 구현 * MOSU-3 feat: RefreshToken 구현 * MOSU-3 feat: PrincipalDetails 구현 * MOSU-3 fix: 주석 제거 및 yml 파일명 변경 * MOSU-3 fix: docker Redis 제거 * MOSU-3 fix: TokenExceptionFilter 생성자 주입 * MOSU-3 fix: 메서드 순서 조정 * MOSU-3 feat: Redis 연결 yml * MOSU-3 fix: final 제거 * MOSU-3 fix: private static final 및 private 메서드 분리 * MOSU-3 feat: Kakao OAuth NPE 처리 * MOSU-3 feat: OAuthProvider 구현
1 parent c98a474 commit 3b633be

34 files changed

+1215
-19
lines changed

build.gradle

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,25 +24,39 @@ repositories {
2424
}
2525

2626
dependencies {
27+
2728
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
2829
implementation 'org.springframework.boot:spring-boot-starter-web'
2930
implementation 'org.springframework.boot:spring-boot-starter-validation'
30-
implementation 'org.springframework.boot:spring-boot-starter-security'
31+
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'
32+
developmentOnly 'org.springframework.boot:spring-boot-devtools'
33+
compileOnly 'org.projectlombok:lombok'
34+
annotationProcessor 'org.projectlombok:lombok'
35+
testImplementation 'org.springframework.boot:spring-boot-starter-test'
36+
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
37+
38+
// jwt
39+
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
40+
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
41+
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
42+
43+
// redis
3144
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
3245
implementation 'org.springframework.boot:spring-boot-starter-data-redis-reactive'
46+
47+
// mail
3348
implementation 'org.springframework.boot:spring-boot-starter-mail'
3449

35-
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'
50+
// mysql
3651
implementation 'org.flywaydb:flyway-core'
3752
implementation 'org.flywaydb:flyway-mysql'
38-
compileOnly 'org.projectlombok:lombok'
39-
developmentOnly 'org.springframework.boot:spring-boot-devtools'
4053
runtimeOnly 'com.mysql:mysql-connector-j'
41-
annotationProcessor 'org.projectlombok:lombok'
42-
testImplementation 'org.springframework.boot:spring-boot-starter-test'
43-
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
4454

45-
//aws
55+
// security
56+
implementation 'org.springframework.boot:spring-boot-starter-security'
57+
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
58+
59+
// aws
4660
implementation platform('software.amazon.awssdk:bom:2.20.0')
4761
implementation 'software.amazon.awssdk:s3'
4862
implementation 'software.amazon.awssdk:sts'

docker-compose/docker-compose.local.yml

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ services:
1010
- database
1111
ports:
1212
- ${SPRING_PORT}:${SPRING_PORT}
13-
restart: always
13+
restart: "always"
1414
networks:
1515
- mosu-bridge
1616

@@ -28,12 +28,33 @@ services:
2828
- ${DB_PORT}
2929
ports:
3030
- ${DB_PORT}:${DB_PORT}
31-
restart: no
31+
restart: "no"
3232
volumes:
3333
- mosu-database:/var/lib/mysql
3434
networks:
3535
- mosu-bridge
3636

37+
velkey:
38+
image: ghcr.io/valkey-io/valkey:7.2
39+
container_name: mosu-velkey
40+
command: [ "valkey-server", "--port", "${VELKEY_PORT}", "--requirepass","${REDIS_PASSWORD}"]
41+
ports:
42+
- ${VELKEY_PORT}:${VELKEY_PORT}
43+
networks:
44+
- mosu-bridge
45+
46+
redis-exporter:
47+
image: oliver006/redis_exporter:v1.58.0
48+
container_name: mosu-redis-exporter
49+
environment:
50+
REDIS_ADDR: redis://velkey:${VELKEY_PORT}
51+
ports:
52+
- ${REDIS_EXPORTER}:${REDIS_EXPORTER}
53+
depends_on:
54+
- velkey
55+
networks:
56+
- mosu-bridge
57+
3758
volumes:
3859
mosu-database:
3960

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package life.mosu.mosuserver.applicaiton.auth;
2+
3+
import life.mosu.mosuserver.domain.user.UserJpaRepository;
4+
import org.springframework.beans.factory.annotation.Autowired;
5+
import org.springframework.beans.factory.annotation.Value;
6+
import org.springframework.stereotype.Service;
7+
8+
@Service
9+
public class AccessTokenService extends JwtTokenService {
10+
11+
@Autowired
12+
public AccessTokenService(
13+
@Value("${jwt.access-token.expire-time}") final Long expireTime,
14+
@Value("${jwt.secret}") final String secretKey,
15+
final UserJpaRepository userRepositoy
16+
) {
17+
super(expireTime, secretKey, "Access", "Authorization", userRepositoy);
18+
}
19+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package life.mosu.mosuserver.applicaiton.auth;
2+
3+
import jakarta.servlet.http.HttpServletRequest;
4+
import life.mosu.mosuserver.domain.user.UserJpaEntity;
5+
import life.mosu.mosuserver.presentation.auth.dto.Token;
6+
import lombok.RequiredArgsConstructor;
7+
import org.springframework.security.core.Authentication;
8+
import org.springframework.stereotype.Component;
9+
10+
@Component
11+
@RequiredArgsConstructor
12+
public class AuthTokenManager {
13+
14+
private final AccessTokenService accessTokenService;
15+
private final RefreshTokenService refreshTokenService;
16+
17+
public Token generateAuthToken(final UserJpaEntity user) {
18+
final String accessToken = accessTokenService.generateJwtToken(user);
19+
final String refreshToken = refreshTokenService.generateJwtToken(user);
20+
21+
refreshTokenService.cacheRefreshToken(user.getId(), refreshToken);
22+
23+
return Token.of(JwtTokenService.BEARER_TYPE, accessToken, refreshToken);
24+
}
25+
26+
public Token reissueAccessToken(final HttpServletRequest servletRequest) {
27+
final String refreshTokenString = refreshTokenService.resolveToken(servletRequest);
28+
29+
final Authentication authentication = refreshTokenService.getAuthentication(refreshTokenString);
30+
final PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
31+
final UserJpaEntity user = principalDetails.user();
32+
33+
refreshTokenService.deleteRefreshToken(user.getId());
34+
35+
return generateAuthToken(user);
36+
}
37+
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package life.mosu.mosuserver.applicaiton.auth;
2+
3+
import io.jsonwebtoken.*;
4+
import io.jsonwebtoken.io.Decoders;
5+
import io.jsonwebtoken.security.Keys;
6+
import jakarta.servlet.http.HttpServletRequest;
7+
import life.mosu.mosuserver.domain.user.OAuthUserJpaEntity;
8+
import life.mosu.mosuserver.domain.user.UserJpaEntity;
9+
import life.mosu.mosuserver.domain.user.UserJpaRepository;
10+
import life.mosu.mosuserver.global.exception.CustomRuntimeException;
11+
import life.mosu.mosuserver.global.exception.ErrorCode;
12+
import lombok.extern.slf4j.Slf4j;
13+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
14+
import org.springframework.security.core.Authentication;
15+
16+
import java.security.Key;
17+
import java.util.Date;
18+
19+
@Slf4j
20+
public abstract class JwtTokenService {
21+
22+
protected static final String TOKEN_TYPE_KEY = "type";
23+
protected static final String BEARER_TYPE = "Bearer";
24+
25+
protected final Long expireTime;
26+
protected final Key key;
27+
protected final String tokenType;
28+
protected final String header;
29+
protected final UserJpaRepository userRepository;
30+
31+
protected JwtTokenService(
32+
final Long expireTime,
33+
final String secretKey,
34+
final String tokenType,
35+
final String header,
36+
final UserJpaRepository userJpaRepository
37+
) {
38+
this.expireTime = expireTime;
39+
this.key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey));
40+
this.tokenType = tokenType;
41+
this.header = header;
42+
this.userRepository = userJpaRepository;
43+
}
44+
45+
/**
46+
* JWT 토큰을 생성한다.
47+
*
48+
* @param user 토큰을 생성할 회원
49+
* @return 생성된 토큰
50+
*/
51+
public String generateJwtToken(final UserJpaEntity user) {
52+
final long now = System.currentTimeMillis();
53+
final Date expireTime = new Date(now + this.expireTime);
54+
55+
return Jwts.builder()
56+
.setSubject(user.getLoginId())
57+
.claim(TOKEN_TYPE_KEY, tokenType)
58+
.setExpiration(expireTime)
59+
.signWith(key, SignatureAlgorithm.HS256)
60+
.compact();
61+
}
62+
63+
/**
64+
* JWT 토큰을 생성한다.
65+
*
66+
* @param user 토큰을 생성할 회원
67+
* @return 생성된 토큰
68+
*/
69+
public String generateAccessToken(final OAuthUserJpaEntity user) {
70+
final long now = System.currentTimeMillis();
71+
final Date expireTime = new Date(now + this.expireTime);
72+
73+
return Jwts.builder()
74+
.setSubject(user.getEmail())
75+
.claim(TOKEN_TYPE_KEY, tokenType)
76+
.setExpiration(expireTime)
77+
.signWith(key, SignatureAlgorithm.HS256)
78+
.compact();
79+
}
80+
81+
/**
82+
* JWT 토큰을 파싱하여 Authentication 객체를 생성한다.
83+
*
84+
* @param token 토큰
85+
* @return 생성된 Authentication
86+
*/
87+
public Authentication getAuthentication(final String token) {
88+
final Claims claims = validateAndParseToken(token);
89+
final String loginId = claims.getSubject();
90+
final UserJpaEntity user = userRepository.findByLoginId(loginId)
91+
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다."));
92+
final PrincipalDetails principalDetails = new PrincipalDetails(user);
93+
94+
return new UsernamePasswordAuthenticationToken(principalDetails, "", principalDetails.getAuthorities());
95+
}
96+
97+
private Claims parseClaims(String token) {
98+
try {
99+
return Jwts.parserBuilder()
100+
.setSigningKey(key)
101+
.build()
102+
.parseClaimsJws(token)
103+
.getBody();
104+
} catch (ExpiredJwtException exception) {
105+
return exception.getClaims();
106+
}
107+
}
108+
109+
/**
110+
* 토큰 검증과 함께 파싱을 수행한다.
111+
*
112+
* @param token 토큰
113+
* @return 파싱된 Claims
114+
*/
115+
protected Claims validateAndParseToken(final String token) {
116+
try {
117+
final Claims claims = parseClaims(token);
118+
119+
if (!claims.get(TOKEN_TYPE_KEY).equals(tokenType)) {
120+
throw new CustomRuntimeException(ErrorCode.INVALID_TOKEN_TYPE);
121+
}
122+
123+
if (claims.getExpiration().toInstant().isBefore(new Date().toInstant())) {
124+
throw new CustomRuntimeException(ErrorCode.EXPIRED_TOKEN);
125+
}
126+
return claims;
127+
} catch (JwtException | IllegalArgumentException exception) {
128+
throw new CustomRuntimeException(ErrorCode.INVALID_TOKEN_TYPE);
129+
}
130+
}
131+
132+
/**
133+
* HttpServletRequest에서 토큰을 추출한다. 토큰이 없는 경우 null을 반환한다.
134+
*
135+
* @param request HttpServletRequest
136+
* @return 추출된 토큰
137+
*/
138+
public String resolveToken(final HttpServletRequest request) {
139+
final String header = request.getHeader(this.header);
140+
141+
if (header != null && header.startsWith(BEARER_TYPE)) {
142+
return header.replace(BEARER_TYPE, "").trim();
143+
}
144+
return null;
145+
}
146+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package life.mosu.mosuserver.applicaiton.auth;
2+
3+
import life.mosu.mosuserver.domain.user.UserJpaEntity;
4+
import lombok.Getter;
5+
import org.springframework.security.core.GrantedAuthority;
6+
import org.springframework.security.core.authority.SimpleGrantedAuthority;
7+
import org.springframework.security.core.userdetails.UserDetails;
8+
9+
import java.util.ArrayList;
10+
import java.util.Collection;
11+
import java.util.List;
12+
13+
public record PrincipalDetails(@Getter UserJpaEntity user) implements UserDetails {
14+
15+
@Override
16+
public Collection<? extends GrantedAuthority> getAuthorities() {
17+
List<GrantedAuthority> authorities = new ArrayList<>();
18+
authorities.add(new SimpleGrantedAuthority(user.getUserRole().name()));
19+
return authorities;
20+
}
21+
22+
@Override
23+
public String getPassword() {
24+
return user.getPassword();
25+
}
26+
27+
@Override
28+
public String getUsername() {
29+
return user.getLoginId();
30+
}
31+
32+
@Override
33+
public boolean isAccountNonExpired() {
34+
return UserDetails.super.isAccountNonExpired();
35+
}
36+
37+
@Override
38+
public boolean isAccountNonLocked() {
39+
return UserDetails.super.isAccountNonLocked();
40+
}
41+
42+
@Override
43+
public boolean isCredentialsNonExpired() {
44+
return UserDetails.super.isCredentialsNonExpired();
45+
}
46+
47+
@Override
48+
public boolean isEnabled() {
49+
return UserDetails.super.isEnabled();
50+
}
51+
}

0 commit comments

Comments
 (0)