Skip to content

Commit dc49b64

Browse files
authored
Merge pull request #40 from asowjdan/feat/23-member
Feat[member]: 로컬 로그인 회원가입 기능 추가
2 parents 2a57cef + b8db291 commit dc49b64

File tree

12 files changed

+244
-119
lines changed

12 files changed

+244
-119
lines changed

backend/build.gradle

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ dependencies {
3737
implementation 'org.apache.commons:commons-lang3:3.18.0'
3838
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9'
3939

40+
// JWT (JSON Web Token)
41+
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
42+
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
43+
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
44+
4045
// Database (데이터베이스)
4146
runtimeOnly 'com.h2database:h2'
4247
runtimeOnly 'com.mysql:mysql-connector-j'

backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ public class MemberController {
3535
@ApiResponse(responseCode = "400", description = "잘못된 요청 (중복 이메일/닉네임, 유효성 검증 실패)")
3636
})
3737
public ResponseEntity<MemberResponse> signup(@Valid @RequestBody MemberSignupRequest request) {
38-
log.info("회원가입 요청: email={}, nickname={}", request.getLoginId(), request.getNickname());
38+
log.info("회원가입 요청: email={}, name={}", request.getLoginId(), request.getName());
3939

4040
try {
4141
MemberResponse response = memberService.signup(request);
@@ -72,11 +72,19 @@ public ResponseEntity<MemberResponse> login(@Valid @RequestBody MemberLoginReque
7272
@ApiResponses({
7373
@ApiResponse(responseCode = "200", description = "로그아웃 성공")
7474
})
75-
public ResponseEntity<Void> logout(HttpServletResponse response) {
75+
public ResponseEntity<Void> logout(Authentication authentication, HttpServletResponse response) {
7676
log.info("로그아웃 요청");
7777

78-
memberService.logout(response);
79-
log.info("로그아웃 완료");
78+
if (authentication != null && authentication.getName() != null) {
79+
String loginId = authentication.getName();
80+
memberService.logout(loginId, response);
81+
log.info("로그아웃 완료: email={}", loginId);
82+
} else {
83+
// 인증 정보가 없어도 쿠키는 클리어
84+
memberService.logout("", response);
85+
log.info("인증 정보 없이 로그아웃 완료");
86+
}
87+
8088
return ResponseEntity.ok().build();
8189
}
8290

@@ -128,7 +136,7 @@ public ResponseEntity<Void> withdraw(Authentication authentication, HttpServletR
128136
// loginId로 Member를 조회하여 실제 memberId 사용
129137
Member member = memberService.findByLoginId(loginId);
130138
memberService.withdraw(member.getMemberId());
131-
memberService.logout(response); // 탈퇴 후 로그아웃 처리
139+
memberService.logout(loginId, response); // 탈퇴 후 로그아웃 처리
132140
log.info("회원탈퇴 성공: email={}, memberId={}", loginId, member.getMemberId());
133141
return ResponseEntity.ok().build();
134142
} catch (IllegalArgumentException e) {

backend/src/main/java/com/ai/lawyer/domain/member/dto/MemberResponse.java

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ public class MemberResponse {
1414

1515
private Long memberId;
1616
private String loginId;
17-
private String nickname;
1817
private Integer age;
1918
private Member.Gender gender;
2019
private Member.Role role;
@@ -26,7 +25,6 @@ public static MemberResponse from(Member member) {
2625
return MemberResponse.builder()
2726
.memberId(member.getMemberId())
2827
.loginId(member.getLoginId())
29-
.nickname(member.getNickname())
3028
.age(member.getAge())
3129
.gender(member.getGender())
3230
.role(member.getRole())

backend/src/main/java/com/ai/lawyer/domain/member/dto/MemberSignupRequest.java

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,8 @@ public class MemberSignupRequest {
1616
private String loginId;
1717

1818
@NotBlank(message = "비밀번호는 필수입니다")
19-
@Size(min = 8, message = "비밀번호는 최소 8자 이상이어야 합니다")
2019
private String password;
2120

22-
@NotBlank(message = "닉네임은 필수입니다")
23-
@Size(max = 50, message = "닉네임은 50자 이하여야 합니다")
24-
private String nickname;
25-
2621
@NotNull(message = "나이는 필수입니다")
2722
@Min(value = 14, message = "최소 14세 이상이어야 합니다")
2823
private Integer age;

backend/src/main/java/com/ai/lawyer/domain/member/entity/Member.java

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,6 @@ public class Member {
3636
@NotBlank(message = "비밀번호는 필수입니다")
3737
private String password;
3838

39-
@Column(name = "nickname", nullable = false, length = 50)
40-
@NotBlank(message = "닉네임은 필수입니다")
41-
private String nickname;
42-
4339
@Column(name = "age", nullable = false)
4440
@NotNull(message = "나이는 필수입니다")
4541
@Min(value = 14, message = "최소 14세 이상이어야 합니다")

backend/src/main/java/com/ai/lawyer/domain/member/repositories/MemberRepository.java

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,4 @@ public interface MemberRepository extends JpaRepository<Member, Long> {
1212
Optional<Member> findByLoginId(String loginId);
1313

1414
boolean existsByLoginId(String loginId);
15-
16-
boolean existsByNickname(String nickname);
1715
}

backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,10 @@ public class MemberService {
2424
@Transactional
2525
public MemberResponse signup(MemberSignupRequest request) {
2626
validateDuplicateLoginId(request.getLoginId());
27-
validateDuplicateNickname(request.getNickname());
2827

2928
Member member = Member.builder()
3029
.loginId(request.getLoginId())
3130
.password(passwordEncoder.encode(request.getPassword()))
32-
.nickname(request.getNickname())
3331
.age(request.getAge())
3432
.gender(request.getGender())
3533
.name(request.getName())
@@ -58,34 +56,40 @@ public MemberResponse login(MemberLoginRequest request, HttpServletResponse resp
5856
return MemberResponse.from(member);
5957
}
6058

61-
public void logout(HttpServletResponse response) {
59+
public void logout(String loginId, HttpServletResponse response) {
60+
// Redis에서 리프레시 토큰 삭제
61+
tokenProvider.deleteRefreshToken(loginId);
62+
6263
// 쿠키 삭제
6364
cookieUtil.clearTokenCookies(response);
64-
65-
// TODO: 추후 레디스에서 토큰 무효화
6665
}
6766

6867
public MemberResponse refreshToken(String refreshToken, HttpServletResponse response) {
69-
// 리프레시 토큰 유효성 검증
70-
if (!tokenProvider.validateToken(refreshToken)) {
68+
// Redis에서 리프레시 토큰으로 사용자를 찾기 (Redis 키 패턴: refresh_token:loginId)
69+
String username = tokenProvider.findUsernameByRefreshToken(refreshToken);
70+
if (username == null) {
71+
throw new IllegalArgumentException("유효하지 않은 리프레시 토큰입니다.");
72+
}
73+
74+
// Redis에서 리프레시 토큰 유효성 검증
75+
if (!tokenProvider.validateRefreshToken(username, refreshToken)) {
7176
throw new IllegalArgumentException("유효하지 않은 리프레시 토큰입니다.");
7277
}
7378

74-
// TODO: 추후 레디스에서 리프레시 토큰과 매핑된 회원 정보 조회
75-
// 현재는 임시로 토큰에서 사용자명 추출 후 DB 조회
76-
String username = tokenProvider.getUsernameFromToken(refreshToken);
79+
// 회원 정보 조회
7780
Member member = memberRepository.findByLoginId(username)
7881
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다."));
7982

83+
// 기존 리프레시 토큰 Redis에서 삭제 (RTR 패턴)
84+
tokenProvider.deleteRefreshToken(username);
85+
8086
// 새로운 액세스 토큰과 리프레시 토큰 생성
8187
String newAccessToken = tokenProvider.generateAccessToken(member);
8288
String newRefreshToken = tokenProvider.generateRefreshToken(member);
8389

8490
// 새로운 토큰들을 쿠키로 설정
8591
cookieUtil.setTokenCookies(response, newAccessToken, newRefreshToken);
8692

87-
// TODO: 추후 레디스에서 기존 토큰 무효화 및 새 토큰 저장
88-
8993
return MemberResponse.from(member);
9094
}
9195

@@ -114,10 +118,4 @@ private void validateDuplicateLoginId(String loginId) {
114118
throw new IllegalArgumentException("이미 존재하는 이메일입니다.");
115119
}
116120
}
117-
118-
private void validateDuplicateNickname(String nickname) {
119-
if (memberRepository.existsByNickname(nickname)) {
120-
throw new IllegalArgumentException("이미 존재하는 닉네임입니다.");
121-
}
122-
}
123121
}

backend/src/main/java/com/ai/lawyer/global/config/EmbeddedRedisConfig.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,15 @@
33
import lombok.extern.slf4j.Slf4j;
44
import org.springframework.beans.factory.annotation.Value;
55
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
6+
import org.springframework.context.annotation.Bean;
67
import org.springframework.context.annotation.Configuration;
78
import org.springframework.context.event.ContextClosedEvent;
89
import org.springframework.context.event.ContextRefreshedEvent;
910
import org.springframework.context.event.EventListener;
11+
import org.springframework.data.redis.connection.RedisConnectionFactory;
12+
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
13+
import org.springframework.data.redis.core.RedisTemplate;
14+
import org.springframework.data.redis.serializer.StringRedisSerializer;
1015
import redis.embedded.RedisServer;
1116

1217
import jakarta.annotation.PreDestroy;
@@ -16,11 +21,32 @@
1621
@ConditionalOnProperty(name = "spring.data.redis.embedded", havingValue = "true", matchIfMissing = true)
1722
public class EmbeddedRedisConfig {
1823

24+
@Value("${spring.data.redis.host:localhost}")
25+
private String redisHost;
26+
1927
@Value("${spring.data.redis.port:6379}")
2028
private int redisPort;
2129

2230
private RedisServer redisServer;
2331

32+
@Bean
33+
public RedisConnectionFactory redisConnectionFactory() {
34+
return new LettuceConnectionFactory(redisHost, redisPort);
35+
}
36+
37+
@Bean
38+
public RedisTemplate<String, Object> redisTemplate() {
39+
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
40+
redisTemplate.setConnectionFactory(redisConnectionFactory());
41+
42+
redisTemplate.setKeySerializer(new StringRedisSerializer());
43+
redisTemplate.setValueSerializer(new StringRedisSerializer());
44+
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
45+
redisTemplate.setHashValueSerializer(new StringRedisSerializer());
46+
47+
return redisTemplate;
48+
}
49+
2450
@EventListener(ContextRefreshedEvent.class)
2551
public void startRedis() {
2652
try {
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package com.ai.lawyer.global.config;
2+
3+
import org.springframework.beans.factory.annotation.Value;
4+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
5+
import org.springframework.context.annotation.Bean;
6+
import org.springframework.context.annotation.Configuration;
7+
import org.springframework.data.redis.connection.RedisConnectionFactory;
8+
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
9+
import org.springframework.data.redis.core.RedisTemplate;
10+
import org.springframework.data.redis.serializer.StringRedisSerializer;
11+
12+
@Configuration
13+
@ConditionalOnProperty(name = "spring.data.redis.embedded", havingValue = "false")
14+
public class RedisConfig {
15+
16+
@Value("${spring.data.redis.host:localhost}")
17+
private String redisHost;
18+
19+
@Value("${spring.data.redis.port:6379}")
20+
private int redisPort;
21+
22+
@Bean
23+
public RedisConnectionFactory redisConnectionFactory() {
24+
return new LettuceConnectionFactory(redisHost, redisPort);
25+
}
26+
27+
@Bean
28+
public RedisTemplate<String, Object> redisTemplate() {
29+
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
30+
redisTemplate.setConnectionFactory(redisConnectionFactory());
31+
32+
redisTemplate.setKeySerializer(new StringRedisSerializer());
33+
redisTemplate.setValueSerializer(new StringRedisSerializer());
34+
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
35+
redisTemplate.setHashValueSerializer(new StringRedisSerializer());
36+
37+
return redisTemplate;
38+
}
39+
}

backend/src/main/java/com/ai/lawyer/global/jwt/TokenProvider.java

Lines changed: 84 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,45 +2,111 @@
22

33
import com.ai.lawyer.domain.member.entity.Member;
44
import com.ai.lawyer.global.config.JwtProperties;
5+
import io.jsonwebtoken.*;
6+
import io.jsonwebtoken.security.Keys;
57
import lombok.RequiredArgsConstructor;
8+
import lombok.extern.slf4j.Slf4j;
9+
import org.springframework.data.redis.core.RedisTemplate;
610
import org.springframework.stereotype.Component;
711

8-
import java.util.Map;
12+
import javax.crypto.SecretKey;
13+
import java.time.Duration;
14+
import java.util.Date;
915
import java.util.UUID;
10-
import java.util.concurrent.ConcurrentHashMap;
1116

1217
@Component
1318
@RequiredArgsConstructor
19+
@Slf4j
1420
public class TokenProvider {
1521

1622
private final JwtProperties jwtProperties;
23+
private final RedisTemplate<String, Object> redisTemplate;
1724

18-
// 임시로 토큰과 사용자 정보를 매핑하는 메모리 저장소 (추후 레디스로 대체)
19-
private final Map<String, String> tokenToLoginIdMap = new ConcurrentHashMap<>();
25+
private static final String REFRESH_TOKEN_PREFIX = "refresh_token:";
26+
private static final long REFRESH_TOKEN_EXPIRE_TIME = 7 * 24 * 60 * 60; // 7일
27+
28+
private SecretKey getSigningKey() {
29+
return Keys.hmacShaKeyFor(jwtProperties.getSecretKey().getBytes());
30+
}
2031

2132
public String generateAccessToken(Member member) {
22-
// TODO: JWT 의존성 추가 후 실제 JWT 토큰 생성로직으로 변경
23-
// 현재는 임시로 UUID 사용 (추후 레디스에서 매핑 관리)
24-
String token = "access_" + UUID.randomUUID();
25-
tokenToLoginIdMap.put(token, member.getLoginId());
26-
return token;
33+
Date now = new Date();
34+
Date expiry = new Date(now.getTime() + jwtProperties.getAccessToken().getExpirationSeconds() * 1000);
35+
36+
return Jwts.builder()
37+
.setSubject(member.getLoginId())
38+
.setIssuedAt(now)
39+
.setExpiration(expiry)
40+
.claim("memberId", member.getMemberId())
41+
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
42+
.compact();
2743
}
2844

2945
public String generateRefreshToken(Member member) {
30-
// TODO: JWT 의존성 추가 후 실제 JWT 토큰 생성로직으로 변경
31-
// 현재는 임시로 UUID 사용 (추후 레디스에서 매핑 관리)
32-
String token = "refresh_" + UUID.randomUUID();
33-
tokenToLoginIdMap.put(token, member.getLoginId());
34-
return token;
46+
String refreshToken = UUID.randomUUID().toString();
47+
48+
// Redis에 리프레시 토큰 저장
49+
String redisKey = REFRESH_TOKEN_PREFIX + member.getLoginId();
50+
redisTemplate.opsForValue().set(redisKey, refreshToken, Duration.ofSeconds(REFRESH_TOKEN_EXPIRE_TIME));
51+
52+
return refreshToken;
3553
}
3654

3755
public boolean validateToken(String token) {
38-
// TODO: JWT 의존성 추가 후 실제 토큰 검증 로직으로 변경
39-
return token != null && !token.isEmpty() && tokenToLoginIdMap.containsKey(token);
56+
try {
57+
Jwts.parserBuilder()
58+
.setSigningKey(getSigningKey())
59+
.build()
60+
.parseClaimsJws(token);
61+
return true;
62+
} catch (MalformedJwtException e) {
63+
log.warn("잘못된 JWT 토큰: {}", e.getMessage());
64+
} catch (ExpiredJwtException e) {
65+
log.warn("만료된 JWT 토큰: {}", e.getMessage());
66+
} catch (UnsupportedJwtException e) {
67+
log.warn("지원되지 않는 JWT 토큰: {}", e.getMessage());
68+
} catch (IllegalArgumentException e) {
69+
log.warn("JWT 토큰이 잘못되었습니다: {}", e.getMessage());
70+
} catch (SecurityException e) {
71+
log.warn("JWT 서명이 잘못되었습니다: {}", e.getMessage());
72+
}
73+
return false;
4074
}
4175

4276
public String getUsernameFromToken(String token) {
43-
// TODO: JWT 의존성 추가 후 실제 토큰에서 사용자 정보 추출 로직으로 변경
44-
return tokenToLoginIdMap.get(token);
77+
try {
78+
Claims claims = Jwts.parserBuilder()
79+
.setSigningKey(getSigningKey())
80+
.build()
81+
.parseClaimsJws(token)
82+
.getBody();
83+
return claims.getSubject();
84+
} catch (Exception e) {
85+
log.warn("토큰에서 사용자 정보 추출 실패: {}", e.getMessage());
86+
return null;
87+
}
88+
}
89+
90+
public boolean validateRefreshToken(String loginId, String refreshToken) {
91+
String redisKey = REFRESH_TOKEN_PREFIX + loginId;
92+
String storedToken = (String) redisTemplate.opsForValue().get(redisKey);
93+
return refreshToken.equals(storedToken);
94+
}
95+
96+
public void deleteRefreshToken(String loginId) {
97+
String redisKey = REFRESH_TOKEN_PREFIX + loginId;
98+
redisTemplate.delete(redisKey);
99+
}
100+
101+
public String findUsernameByRefreshToken(String refreshToken) {
102+
String pattern = REFRESH_TOKEN_PREFIX + "*";
103+
var keys = redisTemplate.keys(pattern);
104+
for (String key : keys) {
105+
String storedToken = (String) redisTemplate.opsForValue().get(key);
106+
if (refreshToken.equals(storedToken)) {
107+
return key.substring(REFRESH_TOKEN_PREFIX.length());
108+
}
109+
}
110+
return null;
45111
}
46112
}

0 commit comments

Comments
 (0)