2022. 8. 1. 14:50ㆍSpring
이번 포스팅에서는 Spring Boot + JWT + Security 를 사용해서 회원가입/로그인 로직을 구현해보겠습니다.
인프런에 있는 Spring Boot JWT Tutorial 강의에서 코드를 참고했으며 그 중 저에게 필요한 부분을 정리하려 합니다.
프로젝트를 바로 진행하기 전에 용어의 개념부터 살펴보겠습니다.
- JWT
- 인증과 인가
1. JWT ( JSON Web Token ) 소개
JWT 정의
JWT는 웹표준(RFC7519)으로 지정되어 있고 Json 객체를 사용해서 Token 자체에 정보들을 저장하고 있는 Web Token이며 토큰 기반의 인증 시스템에서 가장 널리 사용되는 인증 방식입니다.
JWT 구조
JWT는 Header, Payload, Signature 세 부분으로 구성되어 있습니다.
Header : Signature를 해싱하기 위한 알고리즘 정보들이 담겨 있습니다.
Payload : 서버와 클라이언트가 시스템에서 실제로 사용되는 정보에 대한 내용을 담고 있습니다.
Signature : Token의 유효성 검증을 위한 암호화된 문자열입니다. 이 문자열을 통해 서버에서는 유효한 Token인지를 검증 할 수 있습니다.
JWT 장점
중앙의 인증 서버와 데이터 스토어에 대한 의존성이 없기 때문에 수평 확장이 용이하다는 큰 장점이 있습니다.
Base64 URL-Safe 인코딩을 이용하기 때문에 URL, Cookie, Header 어디에서든 사용할 수 있는 범용성을 가지고 있습니다.
JWT 단점
토큰 내부에 정보가 저장되기 때문에 노출되면 안 되는 정보를 저장하는 실수를 범할 수 있습니다.
또한, Payload에 저장하는 정보가 많아지면 트래픽 크기가 커질 수 있습니다.
토큰이 서버에 저장되지 않고, 각 클라이언트에 저장되기 때문에 서버에서 각 클라이언트에 저장된 토큰 정보를 직접 조작할 수 없습니다.
2. 인증과 인가란 무엇인가요?
- 인증은 Request를 보낸 User가 올바른 User인지 확인하는 과정을 뜻합니다.(사용자가 본인이 맞는지를 확인)
- 인가는 인증된 사용자에게 시스템 액세스 권한을 부여하는 과정을 뜻합니다.
이제부터 spring-security 프로젝트에 코드를 직접 쳐가면서 진행하겠습니다.
마무리되면 코드도 같이 링크 걸어두겠습니다.
Spring Security가 처음 공부하기에 복잡한 만큼 이번 포스팅의 길이도 길어질 것 같습니다. ㅎㅎ..
처음 공부할 때 어디서부터 손을 대야 할지 막막함을 알기에 대략적인 목차를 적고 시작해보겠습니다.
1. (관련) 도메인 설계
- Studio
- Authority
2. JWT 관련 코드
- TokenProvider
- JwtFilter
- JwtSecurityConfig
- JwtAuthenticationEntryPoint
- JwtAccessDeniedHandler
3. Security 설정
- SecurityConfig
- SecurityUtil
4. RefreshToken 저장소
- RefreshToken
- RefreshTokenRepository
5. Studio 관련 코드
- StudioRepository
- StudioResponseDto
- StudioService
- StudioController
6. 외부 데이터 통신을 위한 DTO
- StudioRequestDto
- TokenDto
- TokenRequestDto
7. 사용자 인증 과정
- AuthService
- AuthController
- CustomUserDetailsService
0. DB 연결
build.gradle > dependencies 추가
dependencies {
// DB
implementation 'mysql:mysql-connector-java'
}
application.yml
application.properties 파일을 Refactor를 이용해 application.yml 로 파일명을 변경하겠습니다.
변경하는 이유는 단지 가독성이 좋기 때문이므로, 본인에게 편한 쪽으로 선택하시면 될 것 같습니다.
# dev-profile
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/[DB명]?serverTimezone=Asia/Seoul&characterEncoding=UTF-8
username: [username]
password: [password]
jpa:
show-sql: true
hibernate:
ddl-auto: update
properties:
hibernate:
format_sql : true
1. Studio도메인 설계, 데이터베이스 확인
Studio
package example.springsecurity.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import javax.persistence.*;
@Getter
@Builder
@AllArgsConstructor
@Entity
public class Studio {
@Id
@GeneratedValue
@Column(name = "studio_id")
private Long id;
private String email;
private String password;
@Enumerated(EnumType.STRING)
private Authority authority;
}
권한은 Enum 클래스로 만들었습니다.
package example.springsecurity.domain;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Getter
public enum StudioRole {
ROLE_STUDIO("ROLE_STUDIO"),
ROLE_ADMIN("ROLE_ADMIN");
private final String value;
}
💡 빌더 패턴 💡
객체를 생성할 때에는 생성자 패턴, 정적 메서드 패턴, 수정자 패턴, 빌더 패턴이 있습니다.
그중에서 빌더 패턴을 사용을 권고하고 있습니다.
장점으로는
1. 필요한 데이터만 설정할 수 있습니다.
- 새로운 변수가 추가되어도 기존 코드에 영향을 주지 않습니다.
- 불필요한 코드의 양을 줄입니다,
- 반복적인 변경이 있을 때 유용합니다.
2. 가독성이 높은 코드입니다.
User user = User.builder()
.name("Pika")
.height(120)
.age(70).build();
4. 변경 가능성을 최소화할 수 있습니다.
- Setter 패턴을 사용할 경우 변경 가능성을 열어두기 때문에 유지보수 시에 잘못된 값이 들어간 지점을
찾기 어렵습니다.
- 변수를 final로 선언함으로써 불변성을 확보하는 것이 제일 좋습니다.
(클래스 위 @Builder @RequiredArgsConstructor적용)
- final을 붙일 수 없는 경우라도 Setter를 넣어주지 않아도 됩니다.
(클래스 위 @Builder @AllArgsConstructor적용)
참고: https://mangkyu.tistory.com/163
Database 확인
2. JWT 코드
JWT 설정 추가
application.yml
...
jwt:
header: Authorization
secret: 4oCYbGVhLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQtbmZ0LXNwcmluZy1ib290LWp3dC10dXRvcmlhbOKAmQo=
token-validity-in-seconds: 86400
application.yml에 jwt 관련 설정을 추가합니다.
HS512 알고리즘을 사용할 것이기 때문에 secret key는 512bit, 즉 64byte 이상을 사용해야 합니다.
터미널에서 secret key를 base64로 인코딩하여 secret 항목에 채워 넣습니다.
build.gradle 추가
dependencies {
// jwt 관련 라이브러리
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'
}
JWT 관련 코드
- TokenProvider
- JwtFilter
- JwtSecurityConfig
- JwtAuthenticationEntryPoint
- JwtAccessDeniedHandler
TokenProvider
JWT 토큰에 관련된 암호화, 복호화, 검증 로직이 이루어집니다.
package example.springsecurity.jwt;
import io.jsonwebtoken.*;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import lombok.extern.slf4j.Slf4j;
import example.springsecurity.web.dto.TokenDto;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import java.security.Key;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;
@Slf4j
@Component
public class TokenProvider { // JWT 토큰에 관련된 암호화, 복호화, 검증 로직
private static final String AUTHORITIES_KEY = "auth";
private static final String BEARER_TYPE = "bearer";
private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30; // 30분
private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7; // 7일
private Key key;
// @Value("${jwt.secret}")
// private String secret;
// application.yml에서 주입받은 secret 값을 base64 decode하여 key 변수에 할당
public TokenProvider(@Value("${jwt.secret}") String secret) {
byte[] keyBytes = Decoders.BASE64.decode(secret);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
// Authentication 객체에 포함되어 있는 권한 정보들을 담은 토큰을 생성
public TokenDto generateTokenDto(Authentication authentication) {
// 권한들 가져오기
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
// Access Token 생성
Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
String accessToken = Jwts.builder()
.setSubject(authentication.getName())
.claim(AUTHORITIES_KEY, authorities)
.setExpiration(accessTokenExpiresIn)
.signWith(key, SignatureAlgorithm.HS512)
.compact();
// Refresh Token 생성
String refreshToken = Jwts.builder()
.setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
.signWith(key, SignatureAlgorithm.HS512)
.compact();
return TokenDto.builder()
.grantType(BEARER_TYPE)
.accessToken(accessToken)
.accessTokenExpiresIn(accessTokenExpiresIn.getTime())
.refreshToken(refreshToken)
.build();
}
// JWT토큰을 복호화하여 토큰에 들어있는 정보를 꺼냅니다.
public Authentication getAuthentication(String accessToken) {
// 토큰 복호화 : JWT의 body
Claims claims = parseClaims(accessToken);
if(claims.get(AUTHORITIES_KEY) == null) {
throw new RuntimeException("권한 정보가 없는 토큰입니다.");
}
// 클레임에서 권한 정보 가져오기
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// UserDetails 객체를 만들어서 Authentication 리턴
UserDetails principal = new User(claims.getSubject(),"", authorities);
return new UsernamePasswordAuthenticationToken(principal,"",authorities);
}
// 토큰을 검증하는 역할
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
}catch(io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("잘못된 JWT 서면입니다.");
}catch(ExpiredJwtException e) {
log.info("만료된 JWT 토큰입니다.");
}catch(UnsupportedJwtException e) {
log.info("지원되지 않는 JWT토큰입니다.");
}catch (IllegalArgumentException e) {
log.info("JWT 토큰이 잘못되었습니다.");
}
return false;
}
private Claims parseClaims(String accessToken) {
try{
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
}catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
1. 생성자
public TokenProvider(@Value("${jwt.secret}") String secret) {
byte[] keyBytes = Decoders.BASE64.decode(secret);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
application.yml에 정의해놓은 jet.secret값을 가져와서 JWT를 만들 때 사용하는 암호화 키값을 생성합니다.
[ 주의 ]
1. import
import lombok.Value; 가 아닌 import org.springframework.beans.factory.annotation.Value; 해야 합니다.
2. @Value("${jwt.secret}") 관련 에러
기존 코드:
@Value("${jwt.secret}")
private String secret;
public TokenProvider() {
byte[] keyBytes = Decoders.BASE64.decode(secret);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
에러 내용:
Caused by: org.springframework.beans.BeanInstantiationException: Failed to instantiate [nft.nftapplication.jwt.TokenProvider]: Constructor threw exception; nested exception is java.lang.IllegalArgumentException: Decode argument cannot be null.
원인:
지금 생성자에서 @Value로 매핑한 secrect을 쓰고 있는데 @Value가 생성자 실행되는 시점에서는 설정이 안 된 상태이기 때문에 에러 발생
해결방안: @Value로 application.yml의 값 가져오는 방법
2가지 방법
1. 생성자의 인자에서 @Value를 매핑하거나 (제가 선택한 건 이 방법)
2. 생성자에서 secret을 쓰지 않고, 별도 함수를 이후에 호출하는 방식으로 호출 구조를 만들기
참고
@Value로 application.yml의 값 가져오기
2. generateTokenDto
유저 정보를 받아와서 Access Token과 Refresh Token을 생성하는 메서드입니다.
Access Token에는 유저와 권한 정보를 담고 Refresh Token에는 아무 정보도 담지 않습니다.
3. getAuthentication
JWT토큰을 복호화하여 토큰에 들어있는 정보를 꺼냅니다.
UserDetails 객체를 생성해서 UsernamePasswordAuthenticationToken 형태로 리턴하는 이유는 SecurityContext 를 사용하기 위함입니다. (SecurityContext 가 Authentication 객체를 저장합니다.)
4. validateToken
토큰을 검증하는 역할을 수행합니다. Jwts 모듈이 Exception 을 던져줍니다.
JwtFilter
jwt 토큰의 인증 정보를 현재 실행 중인 스레드(Security Context)에 저장합니다.
package example.springsecurity.jwt;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
public static final String AUTHORIZATION_HEADER = "Authorization";
public static final String BEARER_PREFIX = "Bearer";
private final TokenProvider tokenProvider;
// 실제 필터링 로직은 doFilterInternal에서 수행,
// jwt 토큰의 인증 정보를 현재 실행중인 스레드(Security Context)에 저장합니다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String requestURI = request.getRequestURI();
// 1. Request Header 에서 토큰을 꺼냄
String jwt = resolveToken(request);
// 2. validateToken 으로 토큰 유효성 검사, 정상 토큰이면 해당 토큰으로 Authentication을 가져와서 SecurityContext에 저장
if(StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
Authentication authentication = tokenProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.info("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.getName(), requestURI);
}else {
log.debug("유효한 JWT 토큰이 없습니다, uri: {}", requestURI);
}
filterChain.doFilter(request, response);
}
// HttpServletRequest 객체의 Header에서 token을 꺼내는 역할을 수행
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if(StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(7);
}
return null;
}
}
OncePerRequestFilter 인터페이스를 구현하기 때문에 요청받을 때 단 한 번만 실행됩니다.
JwtFilter빈은 TokenProvider를 주입받습니다.
실제 필터링 로직은 doFilterInternal메소드를 오버라이드 하여 작성합니다.
doFilterInternal
- 실제 필터링 로직을 수행하는 곳입니다.
- Request Header 에서 Access Token 을 꺼내고 jwt 토큰의 인증 정보를 현재 실행 중인 스레드인 SecurityContext 에 저장합니다.
- 가입/로그인/재발급을 제외한 모든 Request 요청은 이 필터를 거치기 때문에 토큰 정보가 없거나 유효하지 않으면 정상적으로 수행되지 않습니다.
- 그리고 요청이 정상적으로 Controller 까지 도착했다면 SecurityContext 에 Studio ID 가 존재한다는 것이 보장됩니다.
- 대신 직접 DB 를 조회한 것이 아니라 Access Token 에 있는 Studio ID 를 꺼낸 거라서, 탈퇴로 인해 Studio ID 가 DB 에 없는 경우 등 예외 상황은 Service 단에서 고려해야 합니다.
resolveToken
HttpServletRequest 객체의 Header에서 token을 꺼내는 역할을 수행합니다.
💡 HttpServletRequest, HttpServletResponse 💡
WAS가 웹브라우저로부터 Servlet요청을 받으면
1. 요청을 받을 때 전달받은 정보를 HttpServletRequest객체를 생성하여 저장
2. 웹브라우저에게 응답을 돌려줄 HttpServletResponse객체를 생성(빈 객체)
3. 생성된 HttpServletRequest(정보가 저장된)와 HttpServletResponse(비어 있는)를 Servlet에게 전달
HttpServletRequest
1. Http프로토콜의 request 정보를 서블릿에게 전달하기 위한 목적으로 사용
2. Header정보, Parameter, Cookie, URI, URL 등의 정보를 읽어 들이는 메서드를 가진 클래스
3. Body의 Stream을 읽어들이는 메소드를 가지고 있음
HttpServletResponse
1. Servlet은 HttpServletResponse객체에 Content Type, 응답 코드, 응답 메시지 등을 담아서 전송함
출처 : https://zester7.tistory.com/33
JwtSeurityConfig
SecurityConfigurerAdapter를 extends 하고 configure메서드를 오버라이드 하여
위에서 만든 JwtFilter를 Security 로직에 적용하는 역할을 수행합니다.
package example.springsecurity.config;
import lombok.RequiredArgsConstructor;
import example.springsecurity.jwt.JwtFilter;
import example.springsecurity.jwt.TokenProvider;
import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
private final TokenProvider tokenProvider;
// TokenProvider 를 주입받아서 JwtFilter 를 통해 Security 로직에 필터를 등록
@Override
public void configure(HttpSecurity http) {
JwtFilter customFilter = new JwtFilter(tokenProvider);
http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
}
}
JwtAuthenticationEntryPoint
AuthenticationEntryPoint를 구현한 클래스로
유효한 자격증명을 제공하지 않고 접근하려 할 때 401 UNAUTHORIZED 에러를 리턴합니다.
package example.springsecurity.jwt;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
// 유효한 자격증명을 제공하지 않고 접근하려 할때 401 UNAUTHORIZED 에러를 리턴
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
JwtAccessDeniedHandler
AccessDeniedHandler를 구현한 클래스로
필요한 권한이 존재하지 않은 경우 403 FORBIDDEN 에러를 리턴합니다.
package example.springsecurity.jwt;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
// 필요한 권한이 존재하지 않은 경우 403 FORBIDDEN 에러를 리턴
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
3. Security 설정
SecurityConfig
WebSecurityConfigurerAdapter 인터페이스의 구현체입니다. Spring Security의 가장 기본적인 설정이며
JWT를 사용하지 않더라도 이 설정은 기본으로 들어갑니다.
package example.springsecurity.config;
import lombok.RequiredArgsConstructor;
import example.springsecurity.jwt.JwtAccessDeniedHandler;
import example.springsecurity.jwt.JwtAuthenticationEntryPoint;
import example.springsecurity.jwt.TokenProvider;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@EnableWebSecurity // 기본적인 Web 보안을 활성화, 추가적인 설정을 위해 WebSecurityConfigurerAdapter를 extends
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final TokenProvider tokenProvider;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// 정적 자원에 대해서는 Security 설정을 적용하지 않음.
// @Override
// public void configure(WebSecurity web) {
// web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
// }
@Override
protected void configure(HttpSecurity http) throws Exception {
// Token 방식을 사용하므로 csrf 설정을 disable 합니다.
http.csrf().disable()
// exception handling 할 때 직접 만든 클래스를 추가
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
// 시큐리티는 기본적으로 세션을 사용하지만 여기선 세션을 사용하지 않기 때문에 세션 설정을 Stateless 로 설정
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
// 로그인, 회원가입 API 는 토큰이 없는 상태에서 요청이 들어오기 때문에 permitAll 설정
.and()
.authorizeRequests()
.antMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated() // 나머지 API 는 전부 인증 필요
.and()
.apply(new JwtSecurityConfig(tokenProvider));
}
}
@EnableWebSecurity
기본적인 Web 보안을 활성화하겠다는 어노테이션입니다.
추가적인 설정을 위해서
WebSecurityConfigurer을 implements 하거나 WebSecurityConfigurerAdapter를 extends 하는 방법이 있습니다.
여기서는 WebSecurityConfigurerAdapter을 extends 하여 진행했습니다.
SecurityUtil
package example.springsecurity.util;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import java.awt.*;
@Slf4j
public class SecurityUtil {
private SecurityUtil() {}
// JwtFilter 클래스의 doFilter 메소드에서 저장한 Security Context의 인증 정보에서 username을 리턴
// 유저 정보에서 Studio ID 만 반환하는 메소드
public static Long getCurrentStudioId() {
final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if(authentication == null || authentication.getName() == null) {
throw new RuntimeException("Security Context 에 인증 정보가 없습니다.");
}
return Long.parseLong(authentication.getName());
}
}
- JwtFilter 에서 SecurityContext 에 세팅한 유저 정보를 꺼냅니다.
- StudioId 를 저장하게 했으므로 꺼내서 Long 타입으로 파싱 하여 반환합니다.
- SecurityContext 는 ThreadLocal 에 사용자의 정보를 저장합니다.
ThreadLocal
스레드 단위로 로컬 변수를 사용할 수 있기 때문에 마치 전역 변수처럼 여러 메서드에서 활용할 수 있다.
4. Refresh Token 저장소
Access Token 과 Refresh Token 을 함께 사용하기 때문에 저장이 필요합니다.
RefreshToken
package example.springsecurity.domain;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
@Getter
@Builder
@Entity
public class RefreshToken {
@Id
@Column(name = "rt_key" )
private String key;
@Column(name = "rt_value")
private String value; // Refresh Token String
public RefreshToken updateValue(String token) {
this.value = token;
return this;
}
}
- key 에는 Studio ID 값이 들어갑니다.
- value 에는 Refresh Token String 이 들어갑니다.
- RDB 로 구현 시 생성/수정 시간 컬럼을 추가하여 배치 작업으로 만료된 토큰들을 삭제해주어야 합니다.
RefreshTokenRepository
Studio ID 값으로 토큰을 가져오기 위한 findByKey입니다.
package example.springsecurity.repository;
import example.springsecurity.domain.RefreshToken;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByKey(String key);
}
5. Studio 관련 코드
StudioRepository
package example.springsecurity.repository;
import example.springsecurity.domain.Studio;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface StudioRepository extends JpaRepository<Studio, Long> {
Optional<Studio> findByEmail(String email);
boolean existsByEmail(String email); // 이미 가입된 이메일인지 확인용
}
StudioResponseDto
package example.springsecurity.web.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import example.springsecurity.domain.Studio;
@Getter
@AllArgsConstructor
public class StudioResponseDto {
private String email;
public static StudioResponseDto studioResponseDto(Studio studio) {
return new StudioResponseDto(studio.getEmail());
}
}
StudioService
Studio 정보를 가져옵니다.
package example.springsecurity.service;
import lombok.RequiredArgsConstructor;
import example.springsecurity.domain.Studio;
import example.springsecurity.repository.StudioRepository;
import example.springsecurity.util.SecurityUtil;
import example.springsecurity.web.dto.StudioResponseDto;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class StudioService {
private final StudioRepository studioRepository;
@Transactional(readOnly = true)
public StudioResponseDto getStudioInfo(String email) {
return studioRepository.findByEmail(email)
.map(StudioResponseDto::studioResponseDto)
.orElseThrow(() -> new RuntimeException("Studio 정보가 없습니다."));
}
// 현재 SecurityContext 에 있는 Studio 정보 가져오기
@Transactional(readOnly = true)
public StudioResponseDto getLoginStudioInfo() {
return studioRepository.findById(SecurityUtil.getCurrentStudioId())
.map(StudioResponseDto::studioResponseDto)
.orElseThrow(() -> new RuntimeException("로그인 Studio 정보가 없습니다."));
}
}
- 내 정보를 가져올 때는 SecurityUtil.getCurrentStudioId() 를 사용합니다.
- 유저 정보에서 Studio ID 만 반환하는 메서드
- API 요청이 들어오면 필터에서 Access Token 을 복호화해서 유저 정보를 꺼내 SecurityContext 에 저장합니다.
- SecurityContext 에 저장된 유저 정보는 전역으로 어디서든 꺼낼 수 있습니다.
StudioController
package example.springsecurity.web.controller;
import lombok.RequiredArgsConstructor;
import example.springsecurity.service.StudioService;
import example.springsecurity.web.dto.StudioResponseDto;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/studio")
public class StudioController {
private final StudioService studioService;
@GetMapping("/my")
public ResponseEntity<StudioResponseDto> getLoginStudioInfo() {
return ResponseEntity.ok(studioService.getLoginStudioInfo());
}
@GetMapping("/{email}")
public ResponseEntity<StudioResponseDto> getStudioInfo(@PathVariable String email) {
return ResponseEntity.ok(studioService.getStudioInfo(email));
}
}
6. 외부와의 데이터 통신에 사용할 DTO 클래스 생성
- TokenDto
- TokenRequestDto
- StudioRequestDto
- StudioResponseDto
TokenDto
package example.springsecurity.web.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@AllArgsConstructor
@Builder
public class TokenDto {
private String grantType;
private String accessToken;
private String refreshToken;
private Long accessTokenExpiresIn;
}
TokenRequestDto
package example.springsecurity.web.dto;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@NoArgsConstructor
public class TokenRequestDto {
private String accessToken;
private String refreshToken;
}
StudioRequestDto
package nft.nftapplication.web.dto;
import lombok.*;
import nft.nftapplication.domain.Authority;
import nft.nftapplication.domain.Studio;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.crypto.password.PasswordEncoder;
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class StudioRequestDto {
private String email;
private String password;
public Studio studio(PasswordEncoder passwordEncoder) {
return Studio.builder()
.email(email)
.password(passwordEncoder.encode(password))
.authority(Authority.ROLE_STUDIO)
.build();
}
public UsernamePasswordAuthenticationToken toAuthentication() {
return new UsernamePasswordAuthenticationToken(email, password);
}
}
💡 UsernamePasswordAuthenticationToken 💡
- Authentication을 implements 한 AbstractAuthenticationToken의 하위 클래스입니다.
- username이 Principal의 역할을 하고, password가 Credential의 역할을 합니다.
- 첫 번째 생성자는 인증 전의 객체를 생성하고, 두 번째 생성자는 인증이 완료된 객체를 생성해줍니다.
참고 :
https://velog.io/@dnjscksdn98/Spring-Spring-Security%EB%9E%80
public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
// 주로 사용자의 username에 해당함
private final Object principal;
// 주로 사용자의 password에 해당함
private Object credentials;
// 인증 완료 전의 객체 생성
public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
super(null);
this.principal = principal;
this.credentials = credentials;
setAuthenticated(false);
}
// 인증 완료 후의 객체 생성
public UsernamePasswordAuthenticationToken(Object principal, Object credentials,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.credentials = credentials;
super.setAuthenticated(true); // must use super, as we override
}
}
7. 사용자 인증 과정
여기까지 스프링 시큐리티와 JWT 를 사용하기 위한 설정들을 전부 끝냈습니다.
지금부터는 실제로 사용자 로그인 요청이 들어왔을 때 인증 처리 후 JWT 토큰을 발급하는 과정을 알아보도록 하겠습니다.
- AuthService
- AuthController
- CustomUserDetailsService
AuthService
회원가입, 로그인, 토큰 재발급 관련 비즈니스 로직입니다.
package example.springsecurity.service;
import lombok.RequiredArgsConstructor;
import example.springsecurity.domain.RefreshToken;
import example.springsecurity.domain.Studio;
import example.springsecurity.jwt.TokenProvider;
import example.springsecurity.repository.RefreshTokenRepository;
import example.springsecurity.repository.StudioRepository;
import example.springsecurity.web.dto.StudioRequestDto;
import example.springsecurity.web.dto.StudioResponseDto;
import example.springsecurity.web.dto.TokenDto;
import example.springsecurity.web.dto.TokenRequestDto;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class AuthService {
private final AuthenticationManagerBuilder authenticationManagerBuilder;
private final StudioRepository studioRepository;
private final PasswordEncoder passwordEncoder;
private final TokenProvider tokenProvider;
private final RefreshTokenRepository refreshTokenRepository;
/**
* 회원가입
*/
@Transactional
public StudioResponseDto signUp(StudioRequestDto studioRequestDto) {
if(studioRepository.existsByEmail(studioRequestDto.getEmail())) {
throw new RuntimeException("이미 가입되어 있는 스튜디오입니다.");
}
Studio studio = studioRequestDto.studio(passwordEncoder);
return StudioResponseDto.studioResponseDto(studioRepository.save(studio));
}
/**
* 로그인
*/
@Transactional
public TokenDto login(StudioRequestDto studioRequestDto) {
// 1. ID(email)/PW 기반으로 AuthenticationToken 생성
UsernamePasswordAuthenticationToken authenticationToken = studioRequestDto.toAuthentication();
// 2. 실제 검증 로직(사용자 비밀번호 체크)
// authenticate 메서드가 실행이 될 때 CustomUserDetailsService 에서 만든 loadUserByUsername 메서드가 실행됨
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
// 3. 인증 정보를 기반으로 JWT토큰 생성
TokenDto tokenDto = tokenProvider.generateTokenDto(authentication);
// 4. RefreshToken 저장
RefreshToken refreshToken = RefreshToken.builder()
.key(authentication.getName())
.value(tokenDto.getRefreshToken())
.build();
refreshTokenRepository.save(refreshToken);
// 5. 토큰 발급
return tokenDto;
}
/**
* 토큰 재발급
*/
@Transactional
public TokenDto refresh(TokenRequestDto tokenRequestDto) {
// 1. Refresh Token 검증 (validateToken() : 토큰 검증)
if(!tokenProvider.validateToken(tokenRequestDto.getRefreshToken())) {
throw new RuntimeException("Refresh Token이 유효하지 않습니다.");
}
// 2. Access Token에서 Studio ID 가져오기
Authentication authentication = tokenProvider.getAuthentication(tokenRequestDto.getAccessToken());
// 3. 저장소에서 Studio ID를 기반으로 Refresh Token값 가져옴
RefreshToken refreshToken = refreshTokenRepository.findByKey(authentication.getName())
.orElseThrow(() -> new RuntimeException("로그아웃 된 사용자입니다."));
// 4. Refresh Token 일치 여부
if (!refreshToken.getValue().equals(tokenRequestDto.getRefreshToken())) {
throw new RuntimeException("토큰의 유저 정보가 일치하지 않습니다.");
}
// 5. 새로운 토큰 생성
TokenDto tokenDto = tokenProvider.generateTokenDto(authentication);
// 6. 저장소 정보 업데이트
RefreshToken newRefreshToken = refreshToken.updateValue(tokenDto.getRefreshToken());
refreshTokenRepository.save(newRefreshToken);
// 토큰 발급
return tokenDto;
}
}
1. 회원가입 (signUp)
유저 정보를 받아서 저장합니다.
2. 로그인 (login)
- Authentication
- 사용자가 입력한 ID, PW 로 인증 정보 객체 UsernamePasswordAuthenticationToken를 생성합니다.
- 아직 인증이 완료된 객체가 아니며 AuthenticationManager 에서 authenticate 메소드의 파라미터로 넘겨서 검증 후에 Authentication 를 받습니다.
- AuthenticationManager
- 스프링 시큐리티에서 실제로 인증이 이루어지는 곳입니다.
- authenticate 메소드 하나만 정의되어 있는 인터페이스로 Builder 에서 UserDetails 의 유저 정보가 서로 일치하는지 검사합니다. 내부적으로 수행되는 검증 과정은 아래의 CustomUserDetailsService 클래스에서 다루겠습니다.
- 인증이 완료된 authentication 에는 Studio ID 가 들어있습니다.
- 인증 객체를 바탕으로 Access Token + Refresh Token 을 생성합니다.
- Refresh Token 은 저장하고, 생성된 토큰 정보를 클라이언트에게 전달합니다.
3. 토큰 재발급 (refresh)
- Access Token + Refresh Token 을 Request Body 에 받아서 검증합니다.
- Refresh Token 의 만료 여부를 먼저 검사합니다.
- Access Token 을 복호화하여 유저 정보 (Studio ID) 를 가져오고 저장소에 있는 Refresh Token 과 클라이언트가 전달한 Refresh Token 의 일치 여부를 검사합니다.
- 만약 일치한다면 로그인했을 때와 동일하게 새로운 토큰을 생성해서 클라이언트에게 전달합니다.
- Refresh Token 은 재사용하지 못하게 저장소에서 값을 갱신해줍니다.
AuthController
회원가입, 로그인, 토큰 재발급을 처리하는 API 입니다.
package example.springsecurity.web.controller;
import jdk.nashorn.internal.parser.Token;
import lombok.RequiredArgsConstructor;
import example.springsecurity.service.AuthService;
import example.springsecurity.web.dto.StudioRequestDto;
import example.springsecurity.web.dto.StudioResponseDto;
import example.springsecurity.web.dto.TokenDto;
import example.springsecurity.web.dto.TokenRequestDto;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping("/signup")
public ResponseEntity<StudioResponseDto> signUp(@RequestBody StudioRequestDto studioRequestDto) {
return ResponseEntity.ok(authService.signUp(studioRequestDto));
}
@PostMapping("/login")
public ResponseEntity<TokenDto> login(@RequestBody StudioRequestDto studioRequestDto) {
return ResponseEntity.ok(authService.login(studioRequestDto));
}
@PostMapping("/refresh")
public ResponseEntity<TokenDto> refresh(@RequestBody TokenRequestDto tokenRequestDto) {
return ResponseEntity.ok(authService.refresh(tokenRequestDto));
}
}
- SecurityConfig 에서 /api/auth/** 요청은 전부 허용했기 때문에 토큰 검증 로직을 타지 않습니다.
- StudioRequestDto 에는 사용자가 로그인 시도한 ID / PW String 이 존재합니다.
- TokenRequestDto 에는 재발급을 위한 AccessToken / RefreshToken String 이 존재합니다.
CustomStudioDetails
package example.springsecurity.domain;
import lombok.*;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
@ToString
@Builder
@AllArgsConstructor
@Getter
public class CustomStudioDetails implements UserDetails {
private Long id;
private String email;
private String password;
private Collection<GrantedAuthority> role;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return role;
}
@Override
public String getUsername() {
return String.valueOf(this.id);
}
@Override
public boolean isAccountNonExpired() {
return false;
}
@Override
public boolean isAccountNonLocked() {
return false;
}
@Override
public boolean isCredentialsNonExpired() {
return false;
}
@Override
public boolean isEnabled() {
return false;
}
}
CustomUserDetailsService
DB에서 Studio정보를 권한 정보와 함께 가져오는 로직을 구현하였습니다.
package example.springsecurity.service;
import lombok.RequiredArgsConstructor;
import example.springsecurity.domain.CustomStudioDetails;
import example.springsecurity.domain.Studio;
import example.springsecurity.repository.StudioRepository;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collection;
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final StudioRepository studioRepository;
// 로그인 시 authenticate 메소드를 수행할때 DB에서 유저 정보를 조회해오는 loadUserByUsername 메소드가 실행
@Override
@Transactional
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Studio studio = studioRepository.findByEmail(username)
.orElseThrow(() -> new UsernameNotFoundException(username + "을/를 데이터베이스에서 찾을 수 없습니다."));
// DB 에 Studio 값이 존재한다면 studioDetails 객체로 만들어서 리턴
GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(studio.getRole().toString());
CustomStudioDetails studioDetails = CustomStudioDetails.builder()
.id(studio.getId())
.email(studio.getEmail())
.password(studio.getPassword())
.role((Collection<GrantedAuthority>) grantedAuthority)
.build();
return studioDetails;
}
}
💡 UserDetailsService 💡
UserDetailsService는 UserDetails 객체를 반환하는 하나의 메서드만을 가지고 있습니다.
일반적으로 이를 implements 한 클래스에 UserRepository를 주입받아 DB와 연결하여 처리합니다.
package org.springframework.security.core.userdetails;
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
loadUserByUsername
- 로그인 시 authenticate 메서드를 수행하면 DB에서 Studio정보를 조회해오는 loadUserByUsername 메소드가 실행됩니다.
- 로직 구현
- loadUserByUsername 메소드를 오버라이드 해서 DB에서 Studio정보를 권한 정보와 함께 가져옵니다.
Postman을 이용한 API 호출 테스트
1. 회원가입
http://localhost:8080/api/auth/signup
DB
2. 로그인
[[ 에러 ]]
원인 : 시큐리티에 Authentication이 등록이 안 됨.
확인 사항 :
로그인해서 토큰 받아오는지 확인 후
토큰 받아오는 데 성공했으면 포스트맨 Header 쪽에 토큰 실어서 보내기.
(브라우저는 자동인데, 포스트맨은 따로 실어줘야 하기 때문입니다.)
자세히 설명하려다 보니 포스팅의 길이가 너무 길어진 것 같습니다.
위와 같은 문제를 디버깅을 통해서 해결하는 과정은 다음 포스팅에서 기술하도록 하겠습니다.
💡 Spring Security JWT 로그인 인증 흐름
1. 로그인 시 JWT 토큰을 생성하여 클라이언트에게 돌려줍니다.
2. 클라이언트는 JWT 토큰을 가지고 있다가 재요청시마다 서버에 JWT 토큰을 가지고 갑니다.
3. 서버는 JWT 토큰이 있으면 해당 토큰을 검증해서 정상이면 로그인했다고 보고
4. DB 리소스에 접근을 허용해주면 되는데, 이때!!
5. 모든 Controller의 함수에 접근하게 할 것인지 특정 Controller의 함수에 접근하게 할 것인지 API 범위를 정해야 합니다.(권한 처리)
6. 이 권한 처리를 직접 하게 되면 엄청 번거로우니 권한 처리만 시큐리티의 도움을 받습니다.
7. JWT 검증이 완료되면 시큐리티에 세션을 만듭니다. 그럼 편리하게 시큐리티의 권한 처리의 도움을 받아 세션을 만들 수 있습니다.
8. 또 다른 방법은 스프링 부트의 인터셉터를 사용할 수 있지만 시큐리티가 더 편리합니다.
참고:
https://velog.io/@dnjscksdn98/Spring-Spring-Security%EB%9E%80
https://dev-coco.tistory.com/174
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-jwt#
https://bcp0109.tistory.com/301
'Spring' 카테고리의 다른 글
Spring, Spring boot란? (컨테이너, DI, IoC) (2) | 2022.09.02 |
---|---|
[타임리프] 자주 사용하는 기능 정리 (0) | 2022.08.17 |
[Spring Security] JWT(Json Web Token)란? (0) | 2022.07.27 |
[Spring Security] 인증 방식 비교(서버 기반 인증, 토큰 기반 인증) (0) | 2022.07.27 |
[Spring Security] Filter와 Interceptor 차이 및 용도 (0) | 2022.07.27 |