[Spring Security] JWT을 알아보자
Spring Boot JWT Tutorial
1. JWT 소개, 프로젝트 생성
2. Data 설정 추가
3. JWT 코드, Security 설정 추가
4. DTO, Repository, 로그인
5. 회원가입, 권한검증
📌 JWT 소개, 프로젝트 생성
JWT란
JWT는 RFC 7519 웹 표준으로 지정이 되어있고 JSON 객체를 사용해서 토큰 자체에 정보들을 저장하고 있는 Web ToKen이다.
JWT는 Header, Payload, Signature 3개의 부분으로 구성되어져 있다.
- Header : Signature를 해싱하기 위한 알고리즘 정보가 담겨있다.
- Payload : 서버와 클라이언트가 주고받는, 시스템에서 실제로 사용될 정보에 대한 내용이 담겨있다.
- Signature : 토큰의 유효성을 검증하기 위한 문자열이 담겨있으며 이 문자열을 통해 서버에서 토큰이 유효한 토큰인지 검증한다.
JWT의 장점
- 중앙의 인증서버, 데이터 스토어에 대한 의존성이 없으므로 시스템 수평 확장이 유리하다.
- Base64 URL Safe Encoding을 이용하기 때문에 URL, Cookie, Header 모두 사용 가능한 범용성을 가지고 있다.
JWT의 단점
- Payload의 정보가 많아지면 네트워크 사용량(트래픽)이 증가하므로 이를 고려해서 설계해야한다.
- 토큰이 서버에 저장되지 않고 각 클라이언트에 저장되기 때문에 서버에서 클라이언트의 토큰을 조작할 수 없다.
프로젝트 생성
- Spring Boot 2.7.13
- Java 17
- https://start.spring.io/ 에서 프로젝트 추가
- IntelliJ를 사용한다면 lombok 어노테이션을 사용하기 위해 Enable annotation processing 체크 필요
📌 Data 설정
예제에서는 User(사용자)와 Authority(권한) 엔티티를 N:M 매핑하여 진행한다.
Entity 생성
✔️ User 엔티티
package com.study.jwttutorial.entity;
import lombok.*;
import javax.persistence.*;
import java.util.Set;
@Entity
@Table(name = "`user`")
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class User {
@Id
@Column(name = "user_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long userId;
@Column(name = "username", length = 50, unique = true)
private String username;
@Column(name = "password", length = 100)
private String password;
@Column(name = "nickname", length = 50)
private String nickname;
@Column(name = "activated")
private boolean activated;
@ManyToMany
@JoinTable(
name = "user_authority",
joinColumns = {@JoinColumn(name = "user_id", referencedColumnName = "user_id")},
inverseJoinColumns = {@JoinColumn(name = "authority_name", referencedColumnName = "authority_name")})
private Set<Authority> authorities;
}
- 실무에서는 엔티티에 Setter를 열어두지 않지만 고려야하지만 예제이기 때문에 롬복 기능을 부담없이 사용했다.
- @ManyToMany와 @JoinTable을 사용하여 일대다 다대일 관계의 조인 테이블을 정의했다.
✔️ Authority 엔티티
package com.study.jwttutorial.entity;
import lombok.*;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;
@Entity
@Table(name = "authority")
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Authority {
@Id
@Column(name = "authority_name", length = 50)
private String authorityName;
}
✔️ data.sql을 사용해서 초기데이터 세팅
insert into "user" (username, password, nickname, activated) values ('admin', '$2a$08$lDnHPz7eUkSi6ao14Twuau08mzhWrL4kyZGGU5xfiGALO/Vxd5DOi', 'admin', 1);
insert into "user" (username, password, nickname, activated) values ('user', '$2a$08$UkVvwpULis18S19S5pZFn.YHPZt3oaqHZnDwqbCW9pft6uFtkXKDC', 'user', 1);
insert into authority (authority_name) values ('ROLE_USER');
insert into authority (authority_name) values ('ROLE_ADMIN');
insert into user_authority (user_id, authority_name) values (1, 'ROLE_USER');
insert into user_authority (user_id, authority_name) values (1, 'ROLE_ADMIN');
insert into user_authority (user_id, authority_name) values (2, 'ROLE_USER');
- 리소스 폴더 밑에 data.sql 생성 및 SQL 작성
- 이후 부터는 data.sql 쿼리들이 서버 실행시 자동으로 실행된다.
📌 JWT 코드, Security 설정 추가
✔️ application.yml
→ JWT 설정 추가
spring:
h2:
console:
enabled: true
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
database-platform: org.hibernate.dialect.H2Dialect
hibernate:
ddl-auto: create-drop
properties:
hibernate:
format_sql: true
show_sql: true
defer-datasource-initialization: true
logging:
level:
me.silvernine: DEBUG
jwt:
header: Authorization
#HS512 알고리즘을 사용할 것이기 때문에 512bit, 즉 64byte 이상의 secret key를 사용해야 한다.
#echo 'silvernine-tech-spring-boot-jwt-tutorial-secret-silvernine-tech-spring-boot-jwt-tutorial-secret'|base64
secret: c2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQtc2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQK
token-validity-in-seconds: 86400
- 이 예제에서 HS512 알고리즘을 사용하기 때문에 Secret Key는 64Byte 이상 되어야한다.
- 토큰의 만료시간은 86400초로 설정
- 참고로 secret 값은 'silvernine-tech-spring-boot-jwt-tutorial-secret-silvernine-tech-spring-boot-jwt-tutorial-secret' 을 Base64로 인코딩한 값으로 64Byte 이상을 만족하는 임의의 값으로 설정하면 된다.
✔️ build.gradle
→ JWT 관련 라이브러리 추가
✔️ SecurityConfig 클래스 추가
→ Security 설정을 위한 클래스
package com.study.jwttutorial.config;
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;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web.ignoring().antMatchers("/h2-console/**"
, "/favicon.ico"
, "/error");
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.authorizeRequests()
.antMatchers("/api/hello").permitAll()
.anyRequest().authenticated();
return httpSecurity.build();
}
}
- @Configuration : 스프링 설정파일로 등록
- @EnableWebSecurity : 기본적인 Web 보안을 활성화 하겠다는 의미
추가적인 설정을 위해 WebSecurityConfigurer를 implements 하거나 WebSecurityConfigurerAdapter를 extends 하는 방법이 있는데 여기서는 WebSecurityConfigurerAdapter을 extends 하여 진행(Spring boot 2.7 이후 필요없음)- configure(WebSecurity web) 메서드 Override
- web.ignoring().antMatchers("/h2-concole/**","favicon.ico")
/h2-concole/ 하위 모든 요청과 파비콘은 모두 무시하는것으로 설정
- web.ignoring().antMatchers("/h2-concole/**","favicon.ico")
- configure(HttpSecurity http) 메서드를 Override
- http
.authorizeRequests() : HttpServletRequest를 사용하는 요청들에 대한 접근 제한을 설정한다는 의미
.antMatchers("/api/hello").permitAll() : 이 요청은 인증없이 접근을 허용
.anyRequest().authenticated() : 나머지의 모든 접근은 인증필요
- http
✔️ TokenProvider 추가
→ 토큰의 생성, 토큰의 유효성 검증등을 담당하는 클래스
package com.study.jwttutorial.jwt;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Value;
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.stereotype.Component;
import java.security.Key;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;
@Component
public class TokenProvider implements InitializingBean { // InitializingBean을 implements해서 afterPropertiesSet을 Override한 이유는 빈이 생성이 되고 의존성 주입을 받은 후에 secret 값을 Base64로 Decode해서 Key 변수에 할당하기 위함이다.
private final Logger logger = LoggerFactory.getLogger(TokenProvider.class);
private static final String AUTHORITIES_KEY = "auth";
private final String secret;
private final long tokenValidityInMilliseconds;
private Key key;
public TokenProvider(
@Value("${jwt.secret}") String secret,
@Value("${jwt.token-validity-in-seconds}") long tokenValidityInSeconds) {
this.secret = secret;
this.tokenValidityInMilliseconds = tokenValidityInSeconds * 1000;
}
@Override
public void afterPropertiesSet() {
byte[] keyBytes = Decoders.BASE64.decode(secret);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
public String createToken(Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
Date validity = new Date(now + this.tokenValidityInMilliseconds);
return Jwts.builder()
.setSubject(authentication.getName())
.claim(AUTHORITIES_KEY, authorities)
.signWith(key, SignatureAlgorithm.HS512)
.setExpiration(validity)
.compact();
}
public Authentication getAuthentication(String token) {
Claims claims = Jwts
.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
User principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, token, authorities);
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
logger.info("잘못된 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
logger.info("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
logger.info("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException e) {
logger.info("JWT 토큰이 잘못되었습니다.");
}
return false;
}
}
- application.yml에 정의한 secret와 token-validity-in-seconds 값을 전역변수로 사용한다.
- InitializingBean을 implements해서 afterPropertiesSet을 Override한 이유는 빈이 생성이 되고 의존성 주입을 받은 후에 secret 값을 Base64로 Decode해서 Key 변수에 할당하기 위함이다.
- public String createToken(Authentication authentication)
- Authentication 객체를 파라미터로 받아서 권한 및 토큰 유효시간을 설정하고 JWT 토큰을 반환한다.
- public Authentication getAuthentication(String token)
- Token을 파라미터로 받아서 Token을 클레임(Payload)으로 만들고 이를 이용해 User 객체를 만들어서 최종적으로 Authentication 객체를 반환한다.
- public boolean validateToken(String token)
- 토큰을 파라미터로 받아서 파싱을 해보고 발생하는 Exception들을 캐치, 문제가 있으면 false, 정상이면 true 반환한다.
✔️ JwtFilter 클래스 생성
→ JWT를 위한 커스텀 필터를 만들기 위한 클래스
package com.study.jwttutorial.jwt;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.GenericFilterBean;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
public class JwtFilter extends GenericFilterBean {
private static final Logger logger = LoggerFactory.getLogger(JwtFilter.class);
public static final String AUTHORIZATION_HEADER = "Authorization";
private TokenProvider tokenProvider;
public JwtFilter(TokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
String jwt = resolveToken(httpServletRequest);
String requestURI = httpServletRequest.getRequestURI();
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
Authentication authentication = tokenProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
logger.debug("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.getName(), requestURI);
} else {
logger.debug("유효한 JWT 토큰이 없습니다, uri: {}", requestURI);
}
filterChain.doFilter(servletRequest, servletResponse);
}
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
- GenericFilterBean을 extends 해서 doFilter를 Override하고 실제 필터링 로직은 doFilter내부에 작성한다.
- doFilter는 토큰의 인증정보를 현재 실행중인 SecurityContext에 저장하는 역할을 수행한다.
- JwtFilter는 전에 만들었던 TokenProvider를 주입받는다.
- public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
- resolveToken을 통해 토큰을 받아와서 유효성 검증을 하고 정상 토큰이면 SecurityContext에 저장
- private String resolveToken(HttpServletRequest request) {
- Request Header에서 Token 정보를 꺼내온다.
✔️ JwtSecurityConfig 클래스 생성
→ TokenProvider, JwtFilter를 SecurityConfig에 적용할 때 사용할 클래스
package com.study.jwttutorial.jwt;
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;
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
private TokenProvider tokenProvider;
public JwtSecurityConfig(TokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}
@Override
public void configure(HttpSecurity http) {
http.addFilterBefore(
new JwtFilter(tokenProvider),
UsernamePasswordAuthenticationFilter.class
);
}
}
- SecurityConfigurerAdapter를 extends하고 TokenProvider를 주입받아서 JwtFilter를 통해 Security 로직에 필터를 등록한다.
✔️ JwtAuthenticationEntryPoint 클래스 생성
→ 유효한 자격증명을 제공하지 않고 접근하려 할 때 401 Unauthorized 에러를 리턴할 클래스
package com.study.jwttutorial.jwt;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
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 {
// 유효한 자격증명을 제공하지 않고 접근하려 할때 401
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
- AuthenticationEntryPoint을 implements 하고 HttpServletResponse.SC_UNAUTHORIZED를 반환하는 클래스이다.
✔️ JwtAccessDeniedHandler 클래스 생성
→ 필요한 권한이 존재하지 않는 경우에 403 Forbidden 에러를 반환하는 클래스
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
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 {
//필요한 권한이 없이 접근하려 할때 403
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
- AccessDeniedHandler을 implements 하고 HttpServletResponse.SC_FORBIDDEN를 반환하는 클래스이다.
✔️ SecurityConfig 클래스 수정
→ 지금까지 작성한 5개의 클래스를 추가
package com.study.jwttutorial.config;
import com.study.jwttutorial.jwt.JwtAccessDeniedHandler;
import com.study.jwttutorial.jwt.JwtAuthenticationEntryPoint;
import com.study.jwttutorial.jwt.TokenProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig{
private final TokenProvider tokenProvider;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
public SecurityConfig(
TokenProvider tokenProvider,
JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
JwtAccessDeniedHandler jwtAccessDeniedHandler
) {
this.tokenProvider = tokenProvider;
this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
this.jwtAccessDeniedHandler = jwtAccessDeniedHandler;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web.ignoring().antMatchers("/h2-console/**"
, "/favicon.ico"
, "/error");
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
// token을 사용하는 방식이기 때문에 csrf를 disable합니다.
.csrf().disable()
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
// enable h2-console
.and()
.headers()
.frameOptions()
.sameOrigin()
// 세션을 사용하지 않기 때문에 STATELESS로 설정
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/api/hello").permitAll()
.antMatchers("/api/authenticate").permitAll()
.antMatchers("/api/signup").permitAll()
.anyRequest().authenticated();
return httpSecurity.build();
}
}
- @EnableGlobalMethodSecurity(prePostEnabled = true) : @PreAuthorize 어노테이션을 메서드 단위로 추가하기 위해서 적용
- SecurityConfig는 생성자를 통해 이전에 만들었던 tokenProvider, jwtAuthenticationEntryPoint, jwtAccessDeniedHandler를 주입받는다.
- PasswordEncoder는 BCryptPasswordEncoder을 사용하기 위해 Bean으로 등록
- protected void configure(HttpSecurity httpSecurity)
- .csrf().disable() : token을 사용하는 방식이기 때문에 csrf를 disable한다.
- .exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler) : exception을 Handling할 때 만들었던 jwtAuthenticationEntryPoint와 jwtAccessDeniedHandler를 클래스로 추가 - .sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) : 세션을 사용하지 않기 때문에 세션 설정을 STATELESS로 설정 - .authorizeRequests()
.antMatchers("/api/authenticate").permitAll()
.antMatchers("/api/signup").permitAll() : 토큰이 없는 상태에서 요청이 들어오는 해당 URI 들은 permitAll 설정 - .anyRequest().authenticated() : 나머지의 모든 접근은 인증필요
- .apply(new JwtSecurityConfig(tokenProvider)) : JwtFilter를 addFileterBefore로 등록했던 JwtSecurityConfig 클래스로 적용
📌 DTO, Repository, 로그인
✔️ LoginDto 클래스 생성
package com.study.jwttutorial.dto;
import lombok.*;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class LoginDto {
@NotNull
@Size(min = 3, max = 50)
private String username;
@NotNull
@Size(min = 3, max = 100)
private String password;
}
✔️ TokenDto 클래스 생성
package com.study.jwttutorial.dto;
import lombok.*;
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class TokenDto {
private String token;
}
✔️ AuthorityDto 클래스 생성
package com.study.jwttutorial.dto;
import lombok.*;
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class AuthorityDto {
private String authorityName;
}
✔️ UserDto 클래스 생성
package com.study.jwttutorial.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.study.jwttutorial.entity.User;
import lombok.*;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import java.util.Set;
import java.util.stream.Collectors;
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class UserDto {
@NotNull
@Size(min = 3, max = 50)
private String username;
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@NotNull
@Size(min = 3, max = 100)
private String password;
@NotNull
@Size(min = 3, max = 50)
private String nickname;
private Set<AuthorityDto> authorityDtoSet;
public static UserDto from(User user) {
if(user == null) return null;
return UserDto.builder()
.username(user.getUsername())
.nickname(user.getNickname())
.authorityDtoSet(user.getAuthorities().stream()
.map(authority -> AuthorityDto.builder().authorityName(authority.getAuthorityName()).build())
.collect(Collectors.toSet()))
.build();
}
}
✔️ UserRepository 인터페이스 생성
package com.study.jwttutorial.repository;
import com.study.jwttutorial.entity.User;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
@EntityGraph(attributePaths = "authorities")
Optional<User> findOneWithAuthoritiesByUsername(String username);
}
- findOneWithAuthoritiesByUsername 메서드는 username을 기준으로 user 정보를 가져올때 권한 정보도 같이 가져온다.
- @EntityGraph은 쿼리가 수행될 때 Lazy 조회가 아니고 Eager 조회로 authorities 정보를 같이 가져오게 된다.
✔️ CustomUserDetailsService 클래스 생성
package com.study.jwttutorial.service;
import com.study.jwttutorial.entity.User;
import com.study.jwttutorial.repository.UserRepository;
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.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;
@Component("userDetailsService")
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public CustomUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
@Transactional
public UserDetails loadUserByUsername(final String username) {
return userRepository.findOneWithAuthoritiesByUsername(username)
.map(user -> createUser(username, user))
.orElseThrow(() -> new UsernameNotFoundException(username + " -> 데이터베이스에서 찾을 수 없습니다."));
}
private org.springframework.security.core.userdetails.User createUser(String username, User user) {
if (!user.isActivated()) {
throw new RuntimeException(username + " -> 활성화되어 있지 않습니다.");
}
List<GrantedAuthority> grantedAuthorities = user.getAuthorities().stream()
.map(authority -> new SimpleGrantedAuthority(authority.getAuthorityName()))
.collect(Collectors.toList());
return new org.springframework.security.core.userdetails.User(user.getUsername(),
user.getPassword(),
grantedAuthorities);
}
}
- UserDetailService를 implements하고 UserRepository를 주입받는다. loadUserByUsername 메서드를 오버라이드해서 로그인시 DB에서 유저정보와 권한정보를 가져오게 된다. 해당 정보를 기반으로 userdetials.User 객체를 생성해서 리턴한다.
✔️ AuthController 클래스 생성
package com.study.jwttutorial.controller;
import com.study.jwttutorial.dto.LoginDto;
import com.study.jwttutorial.dto.TokenDto;
import com.study.jwttutorial.jwt.JwtFilter;
import com.study.jwttutorial.jwt.TokenProvider;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
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.core.context.SecurityContextHolder;
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;
import javax.validation.Valid;
@RestController
@RequestMapping("/api")
public class AuthController {
private final TokenProvider tokenProvider;
private final AuthenticationManagerBuilder authenticationManagerBuilder;
public AuthController(TokenProvider tokenProvider, AuthenticationManagerBuilder authenticationManagerBuilder) {
this.tokenProvider = tokenProvider;
this.authenticationManagerBuilder = authenticationManagerBuilder;
}
@PostMapping("/authenticate")
public ResponseEntity<TokenDto> authorize(@Valid @RequestBody LoginDto loginDto) {
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
String jwt = tokenProvider.createToken(authentication);
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.add(JwtFilter.AUTHORIZATION_HEADER, "Bearer " + jwt);
return new ResponseEntity<>(new TokenDto(jwt), httpHeaders, HttpStatus.OK);
}
}
- AuthController는 TokenProvider, AuthenticationManagerBuilder를 주입받는다. 로그인 API 경로는 /api/authenticate 이고 Post 요청을 받는다.
- LoginDto의 username, password를 파라미터로 받고 이를 이용해 UsernamePasswordAuthenticationToken을 생성한다.
- authenticationToken을 이용해서 Authentication객체를 생성하려고 authenticate메서드가 실행이 될 때CustomUserDetailsService에서 오버라이드한 loadUserByUsername 메서드가 실행된다.
- 결과값을 가지고 Authentication객체를 생성하고 이를 SecurityContext에 저장하고 Authentication 객체를 createToken 메서드를 통해서 JWT Token을 생성한다.
- JWT Token을 Response Header에도 넣어주고 TokenDto를 이용해서 Response Body에도 넣어서 리턴하게 된다.
✔️ 로그인 API 테스트
📌 회원가입, 권한검증
✔️ SecurityUtil 클래스 생성
package com.study.jwttutorial.util;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Optional;
public class SecurityUtil {
private static final Logger logger = LoggerFactory.getLogger(SecurityUtil.class);
private SecurityUtil() {}
public static Optional<String> getCurrentUsername() {
final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null) {
logger.debug("Security Context에 인증 정보가 없습니다.");
return Optional.empty();
}
String username = null;
if (authentication.getPrincipal() instanceof UserDetails) {
UserDetails springSecurityUser = (UserDetails) authentication.getPrincipal();
username = springSecurityUser.getUsername();
} else if (authentication.getPrincipal() instanceof String) {
username = (String) authentication.getPrincipal();
}
return Optional.ofNullable(username);
}
}
- getCurrentUsername메서드의 역할은 Security Context의 Authentication 객체를 이용해 username을 리턴해주는 간단한 유틸성 메서드다.
- Security Context에 Authentication 객체가 저장되는 시점은 JwtFilter의 doFilter 메서드에서 Request가 들어올 때 SecurityContext에 Authentication 객체를 저장해서 사용하게 된다.
✔️ UserService 클래스 생성
package com.study.jwttutorial.service;
import com.study.jwttutorial.dto.UserDto;
import com.study.jwttutorial.entity.Authority;
import com.study.jwttutorial.entity.User;
import com.study.jwttutorial.repository.UserRepository;
import com.study.jwttutorial.util.SecurityUtil;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collections;
@Service
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
@Transactional
public UserDto signup(UserDto userDto) {
if (userRepository.findOneWithAuthoritiesByUsername(userDto.getUsername()).orElse(null) != null) {
throw new RuntimeException("이미 가입되어 있는 유저입니다.");
}
Authority authority = Authority.builder()
.authorityName("ROLE_USER")
.build();
User user = User.builder()
.username(userDto.getUsername())
.password(passwordEncoder.encode(userDto.getPassword()))
.nickname(userDto.getNickname())
.authorities(Collections.singleton(authority))
.activated(true)
.build();
return UserDto.from(userRepository.save(user));
}
@Transactional(readOnly = true)
public UserDto getUserWithAuthorities(String username) {
return UserDto.from(userRepository.findOneWithAuthoritiesByUsername(username).orElse(null));
}
@Transactional(readOnly = true)
public UserDto getMyUserWithAuthorities() {
return UserDto.from(
SecurityUtil.getCurrentUsername()
.flatMap(userRepository::findOneWithAuthoritiesByUsername)
.orElseThrow(() -> new RuntimeException("Member not found"))
);
}
}
- UserService는 UserRepository, PasswordEncoder를 주입 받는다.
- signup 메서드는 회원가입 로직을 수행하는 메서드이다. username이 DB에 존재하지 않으면 Authority와 User 정보를 생성해서 UserRepository의 save메서드를 통해 DB에 정보를 저장한다.
- 여기서 중요한 점은 signup 메서드를 통해 가입한 회원은 USER ROLE을 가지고 있고, data.sql에서 자동 생성되는 admin 계정을 USER, ADMIN ROLE을 가지고 있다. 이 차이를 통해 권한 검증 부분을 테스트 하도록 하겠다.
- UserService에는 유저, 권한정보를 가져오는 메서드가 2개 있다.
getUserWithAuthorities는 username을 기준으로 정보를 가져온다.
getMyUserWithAuthorities는 SecurityContext에 저장된 username의 정보만 가져온다.
이 두가지 메서드의 허용권한을 다르게 해서 권한 검증에 대한 테스트를 하도록 하겠다.
✔️ UserController 클래스 생성
package com.study.jwttutorial.controller;
import com.study.jwttutorial.dto.UserDto;
import com.study.jwttutorial.service.UserService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
import java.io.IOException;
@RestController
@RequestMapping("/api")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/hello")
public ResponseEntity<String> hello() {
return ResponseEntity.ok("hello");
}
@PostMapping("/test-redirect")
public void testRedirect(HttpServletResponse response) throws IOException {
response.sendRedirect("/api/user");
}
@PostMapping("/signup")
public ResponseEntity<UserDto> signup(
@Valid @RequestBody UserDto userDto
) {
return ResponseEntity.ok(userService.signup(userDto));
}
@GetMapping("/user")
@PreAuthorize("hasAnyRole('USER','ADMIN')")
public ResponseEntity<UserDto> getMyUserInfo(HttpServletRequest request) {
return ResponseEntity.ok(userService.getMyUserWithAuthorities());
}
@GetMapping("/user/{username}")
@PreAuthorize("hasAnyRole('ADMIN')")
public ResponseEntity<UserDto> getUserInfo(@PathVariable String username) {
return ResponseEntity.ok(userService.getUserWithAuthorities(username));
}
}
- signup 메서드는 UserDto를 파라미터로 받아 UserService의 signup 메서드를 호출한다.
- getMyUserInfo 메서드는 @PreAuthorize를 통해서 USER, ADMIN 두가지 권한 모두 허용했고, getUserInfo 메서드는 ADMIN 권한만 호출할 수 있도록 설정했다.
- getMyUserInfo 메서드는 UserService에서 만들었던 username 파라미터를 기준으로 유저정보와 권한정보를 리턴한다.
✔️ 회원가입 API 테스트
Postman
- 회원가입 성공하고 tester 유저는 USER 권한을 소유한다.
H2 Console
- tester 유저는 USER 권한을 가지고 있고 admin 유저는 ADMIN, USER 두가지 권한을 소유하고 있다.
- 두 계정을 가지고 허용권한이 달랐던 두개의 API를 테스트 해보겠다.
✔️ 허용권한 테스트 - admin 계정 권한의 토큰으로 접근 테스트
ADMIN 권한을 가진 토큰만 API에 호출이 가능한지 검증하는 테스트이다.
- admin 유저의 토큰을 발급
- admin 계정의 토큰으로 tester 계정의 정보를 가져올 수 있다.
- tester 유저의 토큰 발급
- tester 유저로 발급받은 토큰은 /user/{username} API를 호출하는 권한이 없기때문에 거부된 것을 확인할 수 있다.
✔️ 허용권한 테스트 - user 계정 권한의 토큰으로 접근 테스트
USER, ADMIN 권한을 가진 토큰 모두 API에 호출이 가능한지 검증하는 테스트이다.
- USER 권한으로 호출 가능한 api/user/ API의 접근이 성공한 것을 확인할 수 있다.
해당 글은 인프런의 [Spring Boot JWT Tutorial] 강의를 정리한 내용입니다.
[무료] Spring Boot JWT Tutorial - 인프런 | 강의
Spring Boot, Spring Security, JWT를 이용한 튜토리얼을 통해 인증과 인가에 대한 기초 지식을 쉽고 빠르게 학습할 수 있습니다., [사진] 본 강의는 Spring Boot, Spring Security를 이용해서 JWT 인증과 인가를 쉽
www.inflearn.com