Skip to content

Commit 11852ae

Browse files
authored
Merge pull request #18 from hackathon-soa/feat/#15
feat : 로그인 API 구현 및 JWT 테스트 Controller
2 parents 43f6846 + 6abefcf commit 11852ae

19 files changed

Lines changed: 395 additions & 25 deletions

build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ dependencies {
4242
// AWS S3
4343
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
4444

45+
// JWT
46+
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
47+
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
48+
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
4549
}
4650

4751
tasks.named('test') {
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package hackathon.soa.common;
2+
3+
import hackathon.soa.common.securitry.CustomUserDetailsService;
4+
import jakarta.servlet.FilterChain;
5+
import jakarta.servlet.ServletException;
6+
import jakarta.servlet.http.HttpServletRequest;
7+
import jakarta.servlet.http.HttpServletResponse;
8+
import lombok.RequiredArgsConstructor;
9+
import lombok.extern.slf4j.Slf4j;
10+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
11+
import org.springframework.security.core.context.SecurityContextHolder;
12+
import org.springframework.security.core.userdetails.UserDetails;
13+
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
14+
import org.springframework.stereotype.Component;
15+
import org.springframework.web.filter.OncePerRequestFilter;
16+
17+
import java.io.IOException;
18+
19+
@Slf4j
20+
@Component
21+
@RequiredArgsConstructor
22+
public class JwtAuthenticationFilter extends OncePerRequestFilter {
23+
24+
private final JwtUtil jwtUtil;
25+
private final CustomUserDetailsService userDetailsService;
26+
27+
@Override
28+
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
29+
throws ServletException, IOException {
30+
31+
String authorizationHeader = request.getHeader("Authorization");
32+
String token = jwtUtil.resolveToken(authorizationHeader);
33+
34+
if (token != null && jwtUtil.validateToken(token)) {
35+
try {
36+
Long memberId = jwtUtil.getMemberIdFromToken(token);
37+
UserDetails userDetails = userDetailsService.loadUserByUsername(memberId.toString());
38+
39+
UsernamePasswordAuthenticationToken authToken =
40+
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
41+
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
42+
43+
SecurityContextHolder.getContext().setAuthentication(authToken);
44+
45+
} catch (Exception e) {
46+
log.error("JWT 인증 처리 중 오류 발생: {}", e.getMessage());
47+
}
48+
}
49+
50+
filterChain.doFilter(request, response);
51+
}
52+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package hackathon.soa.common;
2+
3+
import java.lang.annotation.ElementType;
4+
import java.lang.annotation.Retention;
5+
import java.lang.annotation.RetentionPolicy;
6+
import java.lang.annotation.Target;
7+
8+
@Target(ElementType.PARAMETER)
9+
@Retention(RetentionPolicy.RUNTIME)
10+
public @interface JwtUser {
11+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package hackathon.soa.common;
2+
3+
import hackathon.soa.common.securitry.CustomUserDetails;
4+
import org.springframework.core.MethodParameter;
5+
import org.springframework.security.core.Authentication;
6+
import org.springframework.security.core.context.SecurityContextHolder;
7+
import org.springframework.stereotype.Component;
8+
import org.springframework.web.bind.support.WebDataBinderFactory;
9+
import org.springframework.web.context.request.NativeWebRequest;
10+
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
11+
import org.springframework.web.method.support.ModelAndViewContainer;
12+
13+
@Component
14+
public class JwtUserArgumentResolver implements HandlerMethodArgumentResolver {
15+
16+
@Override
17+
public boolean supportsParameter(MethodParameter parameter) {
18+
// @JwtUser 어노테이션이 있고 Long 타입인 파라미터를 지원
19+
return parameter.hasParameterAnnotation(JwtUser.class) &&
20+
parameter.getParameterType().equals(Long.class);
21+
}
22+
23+
@Override
24+
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
25+
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
26+
27+
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
28+
29+
if (authentication != null && authentication.getPrincipal() instanceof CustomUserDetails) {
30+
CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
31+
return userDetails.getMemberId();
32+
}
33+
34+
throw new RuntimeException("인증되지 않은 사용자입니다.");
35+
}
36+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package hackathon.soa.common;
2+
3+
import io.jsonwebtoken.Claims;
4+
import io.jsonwebtoken.JwtException;
5+
import io.jsonwebtoken.Jwts;
6+
import io.jsonwebtoken.SignatureAlgorithm;
7+
import io.jsonwebtoken.security.Keys;
8+
import lombok.extern.slf4j.Slf4j;
9+
import org.springframework.beans.factory.annotation.Value;
10+
import org.springframework.stereotype.Component;
11+
12+
import java.security.Key;
13+
import java.util.Date;
14+
15+
@Slf4j
16+
@Component
17+
public class JwtUtil {
18+
19+
@Value("${jwt.secret}")
20+
private String secretKey;
21+
22+
@Value("${jwt.access-token-expiration}")
23+
private long accessTokenExpiration;
24+
25+
private Key getSigningKey() {
26+
return Keys.hmacShaKeyFor(secretKey.getBytes());
27+
}
28+
29+
// Access Token 생성
30+
public String generateAccessToken(Long memberId) {
31+
Date now = new Date();
32+
Date expiry = new Date(now.getTime() + accessTokenExpiration);
33+
34+
return Jwts.builder()
35+
.setSubject(memberId.toString())
36+
.setIssuedAt(now)
37+
.setExpiration(expiry)
38+
.signWith(getSigningKey(), SignatureAlgorithm.HS512)
39+
.compact();
40+
}
41+
42+
// Token에서 memberId 추출
43+
public Long getMemberIdFromToken(String token) {
44+
Claims claims = Jwts.parserBuilder()
45+
.setSigningKey(getSigningKey())
46+
.build()
47+
.parseClaimsJws(token)
48+
.getBody();
49+
return Long.parseLong(claims.getSubject());
50+
}
51+
52+
// Token 유효성 검증
53+
public boolean validateToken(String token) {
54+
try {
55+
Jwts.parserBuilder().setSigningKey(getSigningKey()).build().parseClaimsJws(token);
56+
return true;
57+
} catch (JwtException | IllegalArgumentException e) {
58+
log.error("Invalid JWT token: {}", e.getMessage());
59+
return false;
60+
}
61+
}
62+
63+
// Authorization 헤더에서 토큰 추출
64+
public String resolveToken(String bearerToken) {
65+
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
66+
return bearerToken.substring(7);
67+
}
68+
return null;
69+
}
70+
}

src/main/java/hackathon/soa/common/apiPayload/code/status/ErrorStatus.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,14 @@ public enum ErrorStatus implements BaseErrorCode {
1717
_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON401", "인증이 필요합니다."),
1818
_FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON403", "금지된 요청입니다."),
1919

20-
// 사용자 관련
20+
// 인증 관련
2121
ID_DUPLICATED(HttpStatus.CONFLICT, "AUTH4001", "이미 사용중인 아이디입니다."),
22+
INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "AUTH4002", "비밀번호가 일치하지 않습니다."),
23+
ACCOUNT_NOT_APPROVED(HttpStatus.FORBIDDEN, "AUTH4003", "승인되지 않은 계정입니다."),
24+
25+
26+
// 사용자 관련
27+
MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER4001", "존재하지 않는 사용자입니다."),
2228
;
2329

2430

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package hackathon.soa.common.securitry;
2+
3+
import hackathon.soa.entity.Member;
4+
import lombok.Getter;
5+
import lombok.RequiredArgsConstructor;
6+
import org.springframework.security.core.GrantedAuthority;
7+
import org.springframework.security.core.userdetails.UserDetails;
8+
9+
import java.util.ArrayList;
10+
import java.util.Collection;
11+
12+
@Getter
13+
@RequiredArgsConstructor
14+
public class CustomUserDetails implements UserDetails {
15+
16+
private final Member member;
17+
18+
@Override
19+
public Collection<? extends GrantedAuthority> getAuthorities() {
20+
return new ArrayList<>();
21+
}
22+
23+
@Override
24+
public String getPassword() {
25+
return member.getPassword();
26+
}
27+
28+
@Override
29+
public String getUsername() {
30+
return member.getAppId();
31+
}
32+
33+
public Long getMemberId() {
34+
return member.getId();
35+
}
36+
37+
@Override
38+
public boolean isAccountNonExpired() {
39+
return true;
40+
}
41+
42+
@Override
43+
public boolean isAccountNonLocked() {
44+
return true;
45+
}
46+
47+
@Override
48+
public boolean isCredentialsNonExpired() {
49+
return true;
50+
}
51+
52+
@Override
53+
public boolean isEnabled() {
54+
return true;
55+
}
56+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package hackathon.soa.common.securitry;
2+
3+
import hackathon.soa.domain.member.MemberRepository;
4+
import hackathon.soa.entity.Member;
5+
import lombok.RequiredArgsConstructor;
6+
import org.springframework.security.core.userdetails.UserDetails;
7+
import org.springframework.security.core.userdetails.UserDetailsService;
8+
import org.springframework.security.core.userdetails.UsernameNotFoundException;
9+
import org.springframework.stereotype.Service;
10+
11+
@Service
12+
@RequiredArgsConstructor
13+
public class CustomUserDetailsService implements UserDetailsService {
14+
15+
private final MemberRepository memberRepository;
16+
17+
@Override
18+
public UserDetails loadUserByUsername(String memberId) throws UsernameNotFoundException {
19+
Member member = memberRepository.findById(Long.parseLong(memberId))
20+
.orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다: " + memberId));
21+
22+
return new CustomUserDetails(member);
23+
}
24+
}

src/main/java/hackathon/soa/config/SecurityConfig.java

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,41 @@
11
package hackathon.soa.config;
22

3+
import hackathon.soa.common.JwtAuthenticationFilter;
34
import lombok.RequiredArgsConstructor;
45
import org.springframework.context.annotation.Bean;
56
import org.springframework.context.annotation.Configuration;
67
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
78
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
9+
import org.springframework.security.config.http.SessionCreationPolicy;
810
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
911
import org.springframework.security.crypto.password.PasswordEncoder;
1012
import org.springframework.security.web.SecurityFilterChain;
13+
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
1114

1215
@Configuration
1316
@EnableWebSecurity
1417
@RequiredArgsConstructor
1518
public class SecurityConfig {
1619

20+
private final JwtAuthenticationFilter jwtAuthenticationFilter;
21+
1722
@Bean
1823
public PasswordEncoder passwordEncoder() {
1924
return new BCryptPasswordEncoder();
2025
}
2126

22-
2327
@Bean
2428
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
2529
http
30+
.csrf(csrf -> csrf.disable())
31+
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
2632
.authorizeHttpRequests(authz -> authz
27-
// TODO 임시적으로 모든 경로 허용
28-
.requestMatchers("/**").permitAll()
29-
// .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() // Swagger 인증 없이 허용
30-
// .requestMatchers("/auth/**").permitAll() // 회원가입 & 로그인 인증 없이 허용
31-
// .anyRequest().authenticated() // 그 외의 경로는 인증 요구
33+
.requestMatchers("/api/auth/**").permitAll() // 로그인, 회원가입 허용
34+
.requestMatchers("/api/home/**", "/api/stories").permitAll() // 홈화면 허용
35+
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() // Swagger 허용
36+
.anyRequest().authenticated() // 그 외 모든 요청은 인증 필요
3237
)
33-
.csrf(csrf -> csrf.disable()); // CSRF 보호 비활성화
38+
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
3439

3540
return http.build();
3641
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package hackathon.soa.config;
2+
3+
import hackathon.soa.common.JwtUserArgumentResolver;
4+
import lombok.RequiredArgsConstructor;
5+
import org.springframework.context.annotation.Configuration;
6+
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
7+
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
8+
9+
import java.util.List;
10+
11+
@Configuration
12+
@RequiredArgsConstructor
13+
public class WebConfig implements WebMvcConfigurer {
14+
15+
private final JwtUserArgumentResolver jwtUserArgumentResolver;
16+
17+
@Override
18+
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
19+
resolvers.add(jwtUserArgumentResolver);
20+
}
21+
}

0 commit comments

Comments
 (0)