이 게시글은 정은구님의 inflearn 강의 Spring Boot JWT Tutorial를 기반으로 작성되었습니다.
JWT는 RFC 7519 웹 표준으로 지정되어있고, JSON 객체를 사용해서 토큰 자체에 정보들을 저장하고 있는 Web Token이다.
JWT는 Header, Payload, Signature 3개의 부분으로 구성되어 있다.
롬복을 사용하기 위해서 Window 환경 IntelliJ 기준으로,
Settings -> Annotation Processors -> Enable annotation processing 을 체크 해준다.
권한 테스트를 위해서 Controller 하나를 작성해준다.
@RestController
@RequestMapping("/api")
public class HelloController {
@GetMapping("/hello")
public ResponseEntity<String> hello() {
return ResponseEntity.ok("hello");
}
}
Rest api 테스트는 Postman에서 진행할 것이다.
지금 get 요청을 해보면, Unauthorized 에러가 발생한다.
이 401 unauthorized 에러를 해결하기 위해 Security 설정을 해보자.
기본적인 Security 설정을 위해서 SecurityConfig 클래스를 만든다.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable) //token을 쓰는 방식이므로 csrf를 disable
.authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests //HttpServletRequest를 사용하는 요청들에 대한 접근제한을 설정하겠다.
.requestMatchers("/api/hello").permitAll() // 해당 주소에 대한 요청은 인증 없이 접근 허용.
.anyRequest().authenticated() //나머지 요청들에 대해서는 인증을 받야아 한다.
);
return http.build();
}
}
@EnableWebSecurity 어노테이션은 기본적인 Web 보안을 활성화 하겠다는 의미이다.
강의에서는 WebSecurityConfigurerAdapter를 extends 하는 방식으로 구현했지만, 버전이 업데이트 되면서 해당 어댑터를 사용할 수 없어서, 위 코드를 사용하였다.
위 코드를 작성하고, 다시 Get 요청을 보내보자.
hello 문자열이 잘 응답 되었다.
본격적인 JWT 튜토리얼을 위해서 Entity 폴더에 User, Authority 엔티티를 만들어보자.
import lombok.*;
import jakarta.persistence.*;
import java.util.Set;
@Entity
@Table(name = "users")
@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;
}
import lombok.*;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "authority")
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Authority {
@Id
@Column(name = "authority_name", length = 50)
private String authorityName;
}
@Getter, @Setter, @...Constructor 등의 Lombok의 기능들은 튜토리얼이니까 부담없이 사용하셨다고 한다. 실무에서는 고려해야되는 점들이 조금 있으니 주의해서 사용해야 한다!
H2 데이터베이스를 연결하기 위해서 yml파일을 설정해주자.
spring:
h2:
console:
enabled: true
datasource:
url: jdbc:h2:tcp://localhost/~/jwttestdb
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
sql:
init:
mode: always
logging:
level:
com.example: DEBUG
sql:init:mode: always는 곧 만들 sql 파일을 실행시키기 위해 넣었다.
resources 폴더 밑에 data.sql file을 만들어준다.
insert into users (username, password, nickname, activated) values ('admin', '$2a$08$lDnHPz7eUkSi6ao14Twuau08mzhWrL4kyZGGU5xfiGALO/Vxd5DOi', 'admin', 1);
insert into users (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 쿼리들이 자동실행된다.
이제, h2-console에 접근을 원활하게 하기 위해서 Security 설정을 추가해주자.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable) //token을 쓰는 방식이므로 csrf를 disable
.authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests
.requestMatchers("/api/hello").permitAll()
.requestMatchers(PathRequest.toH2Console()).permitAll() //<-이 부분 추가
.anyRequest().authenticated()
);
return http.build();
}
}
h2-console 하위 모든 요청들은 SpringSecurity 로직을 수행하지않고 접근할 수 있게 해주었다.
엔티티가 잘 들어간 것을 확인했고, SQL 쿼리도 잘 들어간 것을 확인했다.
기본적인 Security 설정과 Data 설정이 완료되었다.
이제, JWT 관련 코드들을 만들어보자.
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
JWT 관련 의존성을 추가해준다.
jwt:
header: Authorization
secret: c2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQtc2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQK
token-validity-in-seconds: 86400
#토큰의 만료시간은 86400초
JWT 관련 설정을 추가해준다.
- HS512 알고리즘을 사용할 것이기 때문에 512bit, 즉 64byte 이상의 secret key를 사용해야 한다.
- echo 'silvernine-tech-spring-boot-jwt-tutorial-secret-silvernine-tech-spring-boot-jwt-tutorial-secret'|base64
- 위 명령어를 입력하면, 문자열을 base64로 인코딩한 값이 나온다. 그것을 secret key로 사용.
- secret key는 토큰을 발급할 때 사용되는 중요한 키 값이다. 따라서 키 문자열이 yml에 그대로 보이는 것이 신경쓰여서 인코딩을 했다고 하시는데.. 디코딩 해버리면 보이지 않나..? 더 알아봐야겠다. 어쨌든 실무에서는 비공개 해야하는 정보이다!!
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 한다.
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;
}
//주입받은 secret값을 Base64 Decode해서 key변수에 할당
@Override
public void afterPropertiesSet() {
byte[] keyBytes = Decoders.BASE64.decode(secret);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
//Authentication 객체의 권한정보를 이용해서 토큰을 생성하는 createToken 메소드
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); //yml에서 설정했던 토큰 만료시간
//JWT 토큰 생성 후 리턴
return Jwts.builder()
.setSubject(authentication.getName())
.claim(AUTHORITIES_KEY, authorities)
.signWith(key, SignatureAlgorithm.HS512)
.setExpiration(validity)
.compact();
}
//토큰을 파라미터로 받아서 토큰에 담긴 정보를 이용해 Authentication 객체를 리턴하는 메소드
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 객체를 만든다.
User principal = new User(claims.getSubject(), "", authorities);
//User객체, 토큰, 권한정보를 이용해 최종적으로 Authentication 객체를 리턴한다.
return new UsernamePasswordAuthenticationToken(principal, token, authorities);
}
//토큰을 파라미터로 받아서 토큰의 유효성 검증을 수행하는 메소드
public boolean validateToken(String token) {
try {
//받은 토큰으로 파싱을 해보고 발생하는 예외들을 잡는다.
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
//정상이면 true 문제가 있으면 false
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;
}
}
토큰의 생성과 토큰의 유효성 검증을 담당할 클래스이다.
- InitializingBean을 implements해서 afterPropertiesSet을 Override한 이유는 빈이 생성이 되고 의존성 주입까지 받은 후에 주입받은 secret값을 Base64 Decode해서 key변수에 할당하기 위함이다.
- afterPropertiesSet 메소드에서는 우리가 임의의 문자열로 인코딩해놓은 secret을 디코딩해서, key라는 변수에 담는 메소드이다. 이 key는 JWT토큰을 생성할 때 secret값으로 사용된다.
- ${jwt.secret}는 yml에서 설정한 secret key이다.
- ${jwt.token-validity-in-seconds}는 yml에서 설정한 토큰의 유효기간이다.
위 코드들을 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 jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
public class JwtFilter extends GenericFilterBean {
//GenericFilterBean을 extends
private static final Logger logger = LoggerFactory.getLogger(JwtFilter.class);
public static final String AUTHORIZATION_HEADER = "Authorization";
private TokenProvider tokenProvider;
//JwtFilter는 TokenProvider를 주입받는다.
public JwtFilter(TokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}
//JWT 토큰의 인증 정보를 현재 실행중인 SecurityContext에 저장하는 메소드
//실제 필터링 로직을 작성하는 메소드이다.
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
//resolveToken으로 토큰을 받아온다.
String jwt = resolveToken(httpServletRequest);
String requestURI = httpServletRequest.getRequestURI();
//이 토큰의 유효성 검증을 한다.
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
//토큰이 정상적이면 토큰에서 Authentication 객체를 받아와서 SecurityContext에 저장해줌.
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);
}
//필터링을 하기 위해서 토큰 정보가 필요.
//Request Header에서 토큰 정보를 꺼내오기 위한 메소드
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
JWT를 위한 커스텀 필터 클래스이다. doFilter 메소드에서 Request가 들어올때, SecurityContext에 Authentication 객체를 저장해놓는다.
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> {
//SecurityConfigurerAdapter를 extends하고,
private final TokenProvider tokenProvider;
//TokenProvider를 주입받아서,
public JwtSecurityConfig(TokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}
//JwtFilter를 Security 로직에 필터로 등록한다.
@Override
public void configure(HttpSecurity http) {
http.addFilterBefore(
new JwtFilter(tokenProvider),
UsernamePasswordAuthenticationFilter.class
);
}
}
TokenProvider, JwtFilter를 SecurityConfig에 적용할 때 사용할 Config이다.
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.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);
}
}
유효한 자격증명을 제공하지 않고 접근하려 할때 401 Unauthorized 에러를 리턴해주는 클래스이다.
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.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);
}
}
필요한 권한이 존재하지 않는 경우에 403 Forbidden 에러를 리턴해주는 클래스이다.
이렇게 만든 JWT관련 5개의 클래스를 SecurityConfig에 추가해야한다.
import com.example.jwttutorialinflearn.Jwt.JwtAccessDeniedHandler;
import com.example.jwttutorialinflearn.Jwt.JwtAuthenticationEntryPoint;
import com.example.jwttutorialinflearn.Jwt.JwtSecurityConfig;
import com.example.jwttutorialinflearn.Jwt.TokenProvider;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
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.configurers.HeadersConfigurer;
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
@EnableMethodSecurity
public class SecurityConfig {
private final TokenProvider tokenProvider;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
//생성자 주입. 만들었던 JWT관련 클래스를 주입해준다.
public SecurityConfig(
TokenProvider tokenProvider,
JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
JwtAccessDeniedHandler jwtAccessDeniedHandler
) {
this.tokenProvider = tokenProvider;
this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
this.jwtAccessDeniedHandler = jwtAccessDeniedHandler;
}
//PasswordEncoder로는 BCryptPasswordEncoder를 사용한다.
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable) //token을 쓰는 방식이므로 csrf를 disable
//예외를 핸들링할때, 각 예외에 맞게 만들었던 클래스를 추가해준다.
.exceptionHandling(exceptionHandling -> exceptionHandling
.accessDeniedHandler(jwtAccessDeniedHandler) //필요한 권한이 존재하지 않는 경우
.authenticationEntryPoint(jwtAuthenticationEntryPoint) //유효한 자격증명을 제공하지 않고 접근하려할 경우
)
//HttpServletRequest를 사용하는 요청들에 대한 접근제한을 설정하겠다.
.authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests
//(로그인API, 회원가입API)는 토큰이 없는 상태에서 요청이 들어오므로 모두 허용.
.requestMatchers("/api/hello", "/api/authenticate", "/api/signup").permitAll()
.requestMatchers(PathRequest.toH2Console()).permitAll()
.anyRequest().authenticated() //나머지 요청들에 대해서는 인증을 받야아 한다.
)
// 세션을 사용하지 않기 때문에 세션 설정을 STATELESS로 설정
.sessionManagement(sessionManagement ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// enable h2-console
.headers(headers ->
headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)
)
//JwtFilter를 addFilterBefore로 등록했던 JwtSecurityConfig 클래스도 적용해줌.
.with(new JwtSecurityConfig(tokenProvider), customizer -> {});
return http.build();
}
}
이것으로 JWT설정 추가, JWT 관련 코드 개발, Security 설정 추가하는 작업이 완료되었다.
이제 DB와 연결하는 Repository를 만들고, 로그인 API를 구현해보자.
먼저, 외부와의 통신에 사용할 DTO 클래스를 생성해보자
import lombok.*;
import jakarta.validation.constraints.NotNull;
import jakarta.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;
}
로그인시 사용할 로그인 DTO이다.
import lombok.*;
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class TokenDto {
private String token;
}
토큰 정보를 Response할때 사용할 DTO이다.
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.*;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
@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;
}
회원가입시에 사용할 User의 DTO이다.
Repository는 Spring Data JPA를 사용하였다.
import com.example.jwttutorialinflearn.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") //쿼리가 수행될때 Lazy조회가 아닌 Eager조회로 authorities 정보를 같이 가져오게 한다.
Optional<User> findOneWithAuthoritiesByUsername(String username);
}
JPA를 사용했기 때문에, 기본적인 CRUD 메소드를 사용 가능하고, 위와같이 findOneWithAuthoritiesByUsername() 필요한 메소드를 정의해줄 수 있다. username을 기준으로 User정보를 가져올때 권한 정보도 같이 가져오는 메소드이다.
import com.example.jwttutorialinflearn.Entity.User;
import com.example.jwttutorialinflearn.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 {
//Spring Security의 UserDetailsService를 구현한 클래스이다.
private final UserRepository userRepository;
public CustomUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
//로그인 시 DB에서 유저정보와 권한정보를 가져오는 메소드.
@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());
//권한정보, username, password로 User 객체를 리턴해줌.
return new org.springframework.security.core.userdetails.User(user.getUsername(),
user.getPassword(),
grantedAuthorities);
}
}
Spring Security의 중요한 요소인 UserDetailsService를 구현한 클래스이다.
UserDetailsService를 implements하고, loadUserByUsername 메소드를 Override해서 로그인시에 DB에서 유저정보와 권한정보를 가져오도록 해주고, 이 정보를 바탕으로 userdetails.User 객체를 생성해서 return 해준다.
로그인 API를 위한 Controller를 만들어보자.
import com.example.jwttutorialinflearn.Dto.LoginDto;
import com.example.jwttutorialinflearn.Dto.TokenDto;
import com.example.jwttutorialinflearn.Jwt.JwtFilter;
import com.example.jwttutorialinflearn.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 jakarta.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) {
//LoginDto로 들어오는 입력을 받아서 권한토큰을 생성한다.
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());
//권한토큰을 이용하여 Authentication 객체를 생성.
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
//SecurityContext에 저장.
SecurityContextHolder.getContext().setAuthentication(authentication);
//Authentication 객체를 이용하여 JWT 토큰을 생성.
String jwt = tokenProvider.createToken(authentication);
//JWT 토큰을 Response Header에 넣어주고,
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.add(JwtFilter.AUTHORIZATION_HEADER, "Bearer " + jwt);
//TokenDto를 이용해서 Response Body에도 넣어줘서 return 한다.
return new ResponseEntity<>(new TokenDto(jwt), httpHeaders, HttpStatus.OK);
}
}
권한토큰을 이용하여 Authentication 객체를 생성하려고 authenticate메소드가 실행될때, CustomUserDetailsService에서 Override 했던 loadUserByUsername 메소드가 실행된다. 이 실행 결과값으로 Authentication 객체가 생성되는 것이다.
이제 Postman으로 API를 테스트해보자!
이 admin 계정 정보는 data.sql의 insert문이 서버가 시작될때 자동실행되므로 DB에 저장되어있는 상태이다.
성공적으로 토큰이 반환되었다.
Postman의 Tests 탭에서 Response의 데이터를 전역변수에 저장해놓으면, 다른 Request에서도 해당 변수로 데이터를 가져다 쓸 수 있다!
들어가기 전에, 스프링 버전이 업데이트 되면서 추가해야할 코드가 생겼다.
가로줄 표시는 안써도 괜찮음.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.addAllowedOriginPattern("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
source.registerCorsConfiguration("/api/**", config);
return new CorsFilter(source);
}
}
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.filter.CorsFilter;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
private final TokenProvider tokenProvider;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
private final CorsFilter corsFilter; //<-추가
//생성자 주입. 만들었던 JWT관련 클래스를 주입해준다.
public SecurityConfig(
TokenProvider tokenProvider,
JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
JwtAccessDeniedHandler jwtAccessDeniedHandler,
CorsFilter corsFilter) { //<-추가
this.tokenProvider = tokenProvider;
this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
this.jwtAccessDeniedHandler = jwtAccessDeniedHandler;
this.corsFilter = corsFilter; //<-추가
}
//PasswordEncoder로는 BCryptPasswordEncoder를 사용한다.
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable) //token을 쓰는 방식이므로 csrf를 disable
.addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class) //<-추가
.exceptionHandling(exceptionHandling -> exceptionHandling
.accessDeniedHandler(jwtAccessDeniedHandler) //필요한 권한이 존재하지 않는 경우
.authenticationEntryPoint(jwtAuthenticationEntryPoint) //유효한 자격증명을 제공하지 않고 접근하려할 경우
)
//HttpServletRequest를 사용하는 요청들에 대한 접근제한을 설정하겠다.
.authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests
//(로그인API, 회원가입API)는 토큰이 없는 상태에서 요청이 들어오므로 모두 허용.
.requestMatchers("/api/hello", "/api/authenticate", "/api/signup").permitAll()
.requestMatchers(PathRequest.toH2Console()).permitAll()
.anyRequest().authenticated() //나머지 요청들에 대해서는 인증을 받야아 한다.
)
// 세션을 사용하지 않기 때문에 세션 설정을 STATELESS로 설정
.sessionManagement(sessionManagement ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// enable h2-console
.headers(headers ->
headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)
)
//JwtFilter를 addFilterBefore로 등록했던 JwtSecurityConfig 클래스도 적용해줌.
.with(new JwtSecurityConfig(tokenProvider), customizer -> {});
return http.build();
}
}
import java.util.ArrayList;
import java.util.List;
import org.springframework.validation.FieldError;
public class ErrorDto {
private final int status;
private final String message;
private List<FieldError> fieldErrors = new ArrayList<>();
public ErrorDto(int status, String message) {
this.status = status;
this.message = message;
}
public int getStatus() {
return status;
}
public String getMessage() {
return message;
}
public void addFieldError(String objectName, String path, String message) {
FieldError error = new FieldError(objectName, path, message);
fieldErrors.add(error);
}
public List<FieldError> getFieldErrors() {
return fieldErrors;
}
}
import com.example.jwttutorialinflearn.Entity.Authority;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AuthorityRepository extends JpaRepository<Authority, String> {
}
import lombok.*;
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class AuthorityDto {
private String authorityName;
}
import com.example.jwttutorialinflearn.Entity.User;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.*;
import jakarta.validation.constraints.NotNull;
import jakarta.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();
}
}
public class DuplicateMemberException extends RuntimeException {
public DuplicateMemberException() {
super();
}
public DuplicateMemberException(String message, Throwable cause) {
super(message, cause);
}
public DuplicateMemberException(String message) {
super(message);
}
public DuplicateMemberException(Throwable cause) {
super(cause);
}
}
public class NotFoundMemberException extends RuntimeException {
public NotFoundMemberException() {
super();
}
public NotFoundMemberException(String message, Throwable cause) {
super(message, cause);
}
public NotFoundMemberException(String message) {
super(message);
}
public NotFoundMemberException(Throwable cause) {
super(cause);
}
}
이제, 간단한 유틸리티성 메소드들을 만들기 위해 SecurityUtil 클래스를 생성한다.
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() {}
//username을 반환해주는 메소드
public static Optional<String> getCurrentUsername() {
//SecurityContext에서 Authentication 객체를 꺼내온다.
final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
//이 Authentication 객체를 이용해서 username을 반환해주는 간단한 유틸리티성 메소드이다.
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);
}
}
아까 JwtFilter클래스의 doFilter 메소드에서 SecurityContext에 저장해놓은 Authentication 객체를 가져와서 사용하는 것이다.
import java.util.Collections;
import com.example.jwttutorialinflearn.Dto.UserDto;
import com.example.jwttutorialinflearn.Entity.Authority;
import com.example.jwttutorialinflearn.Entity.User;
import com.example.jwttutorialinflearn.Exception.DuplicateMemberException;
import com.example.jwttutorialinflearn.Exception.NotFoundMemberException;
import com.example.jwttutorialinflearn.Repository.UserRepository;
import com.example.jwttutorialinflearn.Util.SecurityUtil;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@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) {
//UserDto로 받은 데이터중 username을 기준으로 하여 DB에 이미 있는지 확인.
if (userRepository.findOneWithAuthoritiesByUsername(userDto.getUsername()).orElse(null) != null) {
throw new DuplicateMemberException("이미 가입되어 있는 유저입니다.");
}
//username이 중복이 없다면 권한정보를 생성
Authority authority = Authority.builder()
.authorityName("ROLE_USER") //ROLE_USER라는 권한을 가짐.
.build();
//받아온 UserDto의 정보와 생성한 권한정보를 이용하여 Entity.User 객체 생성
User user = User.builder()
.username(userDto.getUsername())
.password(passwordEncoder.encode(userDto.getPassword()))
.nickname(userDto.getNickname())
.authorities(Collections.singleton(authority))
.activated(true)
.build();
//DB에 저장.
return UserDto.from(userRepository.save(user));
}
//유저, 권한정보를 가져오는 메소드 2개. 허용권한이 다르므로 권한검증에 대한 테스트로 사용할 것이다.
//username으로 유저 객체, 권한정보를 가져오는 메소드
@Transactional(readOnly = true)
public UserDto getUserWithAuthorities(String username) {
return UserDto.from(userRepository.findOneWithAuthoritiesByUsername(username).orElse(null));
}
//현재 SecurityContext에 저장된 username에 해당하는 유저 객체와 권한정보를 가져오는 메소드
@Transactional(readOnly = true)
public UserDto getMyUserWithAuthorities() {
return UserDto.from(
SecurityUtil.getCurrentUsername()
.flatMap(userRepository::findOneWithAuthoritiesByUsername)
.orElseThrow(() -> new NotFoundMemberException("Member not found"))
);
}
}
import com.example.jwttutorialinflearn.Dto.UserDto;
import com.example.jwttutorialinflearn.Service.UserService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import java.io.IOException;
@RestController
@RequestMapping("/api")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
//회원가입
@PostMapping("/signup")
public ResponseEntity<UserDto> signup(
@Valid @RequestBody UserDto userDto
) {
//UserDto로 받아서 UserService의 signup 메소드 호출
return ResponseEntity.ok(userService.signup(userDto));
}
//===============username을 기준으로 유저 정보와 권한 정보를 리턴하는 API=======================
@GetMapping("/user")
@PreAuthorize("hasAnyRole('USER','ADMIN')") //USER, ADMIN 두가지 권한 모두 호출할 수 있는 API
public ResponseEntity<UserDto> getMyUserInfo(HttpServletRequest request) {
return ResponseEntity.ok(userService.getMyUserWithAuthorities());
}
@GetMapping("/user/{username}")
@PreAuthorize("hasAnyRole('ADMIN')") //ADMIN 권한만 호출할 수 있는 API
public ResponseEntity<UserDto> getUserInfo(@PathVariable String username) {
return ResponseEntity.ok(userService.getUserWithAuthorities(username));
}
}
username, password, nickname을 JSON 값으로 POST 요청한다.
USER 권한을 부여받은 것을 확인할 수 있다.
H2 Console에서 데이터가 잘 들어왔는지 확인해보자.
JRingterm 유저는 USER 권한 하나만을 갖고있고, admin 유저는 ADMIN 권한과 USER 권한을 갖고있다.
이제, 권한이 다른 두 계정을 가지고, 허용 권한이 달랐던 API를 사용해보자.
아까 ADMIN 권한으로 로그인했을때 발급되었던 권한토큰을 Postman 전역변수에 담아놨었다. 그 변수를 Authorization 탭에 Bearer Token 타입으로해서 입력해주고, API를 호출해보았다.
200 OK 가 반환되는 것을 확인할 수 있다.
무슨 일인지 Body에 아무런 값도 출력되지 않는다. 문제가 뭔지 찾아볼 예정...
이 토큰은 ADMIN 권한의 토큰이기 때문에 Admin 권한만 허용되는 API도 호출이 가능했다!
먼저, USER 권한을 갖고있는 hojun 계정으로 로그인해서 권한토큰을 발급받는다. 이 토큰도 마찬가지로 Tests 탭에서 전역변수로 저장했다.
이제 API를 호출해보자.
USER 권한은 이 API를 호출할 수 있는 권한이 없기 때문에, 403 Forbidden 에러가 반환되었다.
우리가 만들어놨던 JwtAccessDeniedHandler가 잘 작동한 것을 알 수 있다.
이 API는 SecurityContext에 저장되어있는 username을 기준으로 유저 정보와 권한 정보를 가져오는 API이다. 즉, 로그인한 자기자신의 정보를 가져오는 것.
USER 권한으로 API를 호출해보자.
USER 권한토큰으로 이 API는 잘 호출되는 것을 볼 수 있다.