Spring Security, JWT, 인증, 인가 #
- Spring Security를 베이스로 JWT를 사용해서 해당 프로젝트의 인증과 인가를 구현한다.
- 이와 관련돼서 생성된 Class는 다음과 같다.
- SecurityConfig : Spring Security관련 설정
- UserAccount : Spring Security에서 인증 요소(principal)로 사용되는 객체. Userdetails를 상속받고 Account의 정보를 갖는다.
- PrincipalDetailService : 인증 시, DB에서 Account를 찾고 UserAccount로 반환하는 loadUserByUsername Method를 갖는다.
- JwtAutienticationFilter : jwt를 사용해서 인증 처리
- JwtAutiorizationFilter : jwt를 사용해서 인가 처리
SecurityConfig #
- Spring Security가 사용할 정책, 필터, 인가 권한 등을 설정한다.
1@Configuration
2@EnableWebSecurity
3@RequiredArgsConstructor
4public class SecurityConfig extends WebSecurityConfigurerAdapter {
5
6 private final AccountRepository accountRepository;
7 private final JwtProcessor jwtProcessor;
8
9 @Override
10 protected void configure(HttpSecurity http) throws Exception {
11 http
12 .csrf().disable()
13 .formLogin().disable()
14 .httpBasic().disable();
15
16 http
17 .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
18
19 http
20 .addFilter(corsFilter())
21 .addFilter(new JwtAuthenticationFilter(authenticationManager(), jwtProcessor))
22 .addFilter(new JwtAuthorizationFilter(authenticationManager(), accountRepository, jwtProcessor));
23
24 http
25 .authorizeRequests()
26 .mvcMatchers("/home", "/login").permitAll() //** 홈페이지, login
27 .anyRequest().hasAuthority("ROLE_USER");
28 }
29
30 @Override
31 public void configure(WebSecurity web) throws Exception {
32 web
33 .ignoring()
34 .requestMatchers(PathRequest.toStaticResources().atCommonLocations());
35 }
36
37 @Bean
38 public CorsFilter corsFilter() {
39 UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
40 CorsConfiguration config = new CorsConfiguration();
41 config.setAllowCredentials(true);
42 config.addAllowedOriginPattern("*");
43 config.addAllowedHeader("*");
44 config.addAllowedMethod("*");
45 source.registerCorsConfiguration("/**", config);
46 return new CorsFilter(source);
47 }
48
49 @Bean
50 @Override
51 public AuthenticationManager authenticationManagerBean() throws Exception {
52 return super.authenticationManagerBean();
53 }기본 설정 #
1http
2 .csrf().disable()
3 .formLogin().disable()
4 .httpBasic().disable();
5
6http
7 .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);- csrf.disable() : API를 작성하는데 프런트가 정해져있지 않기 때문에 csrf설정은 우선 꺼놓는다.
- formLogin.disable() : formLogin 대신 Jwt를 사용하기 때문에 disable로 설정
- httpBasic.disable() : httpBasic 방식 대신 Jwt를 사용하기 때문에 disable로 설정
- SessionCreationPolicy.STATELESS : Jwt를 사용하기 때문에 session을 stateless로 설정한다. stateless로 설정 시 Spring Security는 세션을 사용하지 않는다.
추가 필터 #
1http
2 .addFilter(corsFilter())
3 .addFilter(new JwtAuthenticationFilter(authenticationManager(), jwtProcessor))
4 .addFilter(new JwtAuthorizationFilter(authenticationManager(), accountRepository, jwtProcessor));corsFilter #
1@Bean
2public CorsFilter corsFilter() {
3 UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
4 CorsConfiguration config = new CorsConfiguration();
5 config.setAllowCredentials(true);
6 config.addAllowedOriginPattern("*");
7 config.addAllowedHeader("*");
8 config.addAllowedMethod("*");
9 source.registerCorsConfiguration("/**", config);
10 return new CorsFilter(source);
11}- cors관련 설정을 포함한 필터.
- 기본적으로 서버 또는 지정된 특정 도메인의 요청만 허용하지만 프런트가 정해져있지 않기 때문에 모든 도메인을 허용하는 방식으로 설정.
- setAllowCredentials : 내 서버가 응답을 할 때 json을 자바스크립트에서 처리할수 있게 할지를 설정
- addAllowedOriginPattern : 허용할 도메인 목록
- addAllowedHeader : 허용할 헤더 목록
- addAllowedMethod : 허용할 Method(GET, PUT, 등) 목록
- source.registerCorsConfiguration : 지정한 url에 config 적용
JwtAuthenticationFilter #
- Jwt를 사용한 인증을 구현한 필터
JwtAuthorizationFilter #
- Jwt를 사용한 인가를 구현한 필터
인가 #
1http
2 .authorizeRequests()
3 .mvcMatchers("/home", "/login").permitAll() //** 홈페이지, 로그인
4 .anyRequest().hasAuthority("ROLE_USER");- authorizationRequest : 요청에 따른 인가 설정
- 기본적으로 모든 uri은 ROLE_USER의 권한만 허용
- 홈페이지와 로그인, 스웨거 관련 uri은 모두 허용
- configure(WebSecurity web) : HttpSecurity에서 설정하지 않은 정적리소스와 HTML 등에 관한 권한을 설정한다.
- web.ignoring().requestMathers(PathRequest.toStaticResources().atCommonLocations())
- static 리소스의 자원을 Security에서 제외(Security에서 걸러지지 않고 접근 가능)
- web.ignoring().requestMathers(PathRequest.toStaticResources().atCommonLocations())
authenticationManagerBean #
1@Override
2public void configure(WebSecurity web) throws Exception {
3 web
4 .ignoring()
5 .requestMatchers(PathRequest.toStaticResources().atCommonLocations());
6}- WebSecurityConfigurerAdepter를 상속받은 SecurityConfigure 외에서 AuthenticationManager를 사용하려면 authenticationManagerBean()을 오버라이드 해서 @Bean으로 직접 등록해야 한다.
UserDetails, UserDetailsService #
- Spring Security에서 인증, 인가를 할 때 사용되는 Principal과 관련 서비스 Class를 만든다.
UserAccount #
- Principal은 인증, 인가시 검증되는 객체이기 때문에 본 프로젝트에서 사용되는 회원의 정보인 Account를 갖고 있어야 한다.
1@Getter
2public class UserAccount implements UserDetails {
3
4 private Account account;
5
6 public UserAccount(Account account) {
7 this.account = account;
8 }
9
10 @Override
11 public Collection<? extends GrantedAuthority> getAuthorities() {
12 Collection<GrantedAuthority> authorities = new ArrayList<>();
13 String roleName = account.getRole().getRoleName();
14 authorities.add(() -> roleName);
15 return authorities;
16 }
17
18 @Override
19 public String getPassword() {
20 return account.getPassword();
21 }
22
23 @Override
24 public String getUsername() {
25 return account.getUsername();
26 }
27
28 @Override
29 public boolean isAccountNonExpired() {
30 return true;
31 }
32
33 @Override
34 public boolean isAccountNonLocked() {
35 return true;
36 }
37
38 @Override
39 public boolean isCredentialsNonExpired() {
40 return true;
41 }
42
43 @Override
44 public boolean isEnabled() {
45 return true;
46 }
47}- UserDetails를 상속받는다.
- 회원 계정 엔티티인 Account를 필드로 갖는다.
- getAuthorities() : account의 Role에 저장된 권한 정보를 authorities에 담고 반환한다.
- isAccountNonExpired() : 계정이 만료되지 않았는지를 리턴(true => 만료되지 않음을 의미)
- isAccountNonLocked() : 계정이 잠겨있는지를 리턴(true => 잠겨있지 않음을 의미)
- isCredentialNonExpired() : 계정의 패스워드가 만료되어있는지 를 리턴(true => 만료되지 않음을 의미)
- isEnabled() : 계정이 사용 가능한지를 리턴
PrincipalDetailService #
1@Service
2@RequiredArgsConstructor
3public class PrincipalDetailService implements UserDetailsService {
4
5 private final AccountRepository accountRepository;
6
7 @Override
8 public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
9 Account account = accountRepository.findByUsername(username)
10 .orElseThrow(() -> new NonExistResourceException("해당 username을 갖는 Account를 찾을 수 없습니다."));
11 return new UserAccount(account);
12 }
13}- UserDetailsService를 상속받는다.
loadUserByUsername(String username) #
1@Override
2public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
3 Account account = accountRepository.findByUsername(username)
4 .orElseThrow(() -> new NonExistResourceException("해당 username을 갖는 Account를 찾을 수 없습니다."));
5 return new UserAccount(account);
6}- Spring Security에서 AutenticationManager가 authenticate()를 통해서 인증을 할 때, 지정된 repository에서 인증 대상 객체를 찾아서 Principal 형태로 반환
AccountRepository #
1public interface AccountRepository extends JpaRepository<Account, Long> {
2
3 Optional<Account> findByUsername(String username);
4}- Spring 데이터 JPA를 사용해서 Repository를 생성한다.
- findByUsername : username(= id)으로 Account를 찾아서 반환한다.
Jwt #
- Jwt를 생성하고 디코딩하는 클래스와 Jwt를 사용한 인증, 인가 필터를 구현한 클래스를 만든다.
JwtProcessor #
1@Component
2public class JwtProcessor {
3
4 public String createAuthJwtToken(UserAccount userAccount) {
5 return JWT.create()
6 .withSubject(userAccount.getUsername())
7 .withExpiresAt(new Date(System.currentTimeMillis() + JwtProperties.EXPIRATION_TIME))
8 .withClaim("id", userAccount.getAccount().getId())
9 .withClaim("username", userAccount.getAccount().getUsername())
10 .sign(Algorithm.HMAC512(JwtProperties.SECRET));
11 }
12
13 public String decodeJwtToken(String jwtToken, String secretKey, String claim) {
14 return JWT.require(Algorithm.HMAC512(secretKey)).build()
15 .verify(jwtToken)
16 .getClaim(claim)
17 .asString();
18 }
19
20 public String extractBearer(String jwtHeader) {
21 int pos = jwtHeader.lastIndexOf(" ");
22 return jwtHeader.substring(pos + 1);
23 }
24}creatAuthJwtToken #
1public String createAuthJwtToken(UserAccount userAccount) {
2 return JWT.create()
3 .withSubject(userAccount.getUsername())
4 .withExpiresAt(new Date(System.currentTimeMillis() + JwtProperties.EXPIRATION_TIME))
5 .withClaim("id", userAccount.getAccount().getId())
6 .withClaim("username", userAccount.getAccount().getUsername())
7 .sign(Algorithm.HMAC512(JwtProperties.SECRET));
8}- userAccount를 받아서 JwtToken을 생성하고 반환.
- Account의 id(엔티티의 id)와 username(로그인 시 id로 사용됨)을 HMAC512 알고리즘으로 암호화한다.
- 만료시간은 현재 시간으로부터 JwtProperties(Jwt관련 설정 정보를 모아놓은 클래스)에 정의된 EXPIRATION_TIME까지로 설정
만료시간은 밀리 세컨드로 설정됨 {: .prompt-info }
decodeJwtToken #
1public String decodeJwtToken(String jwtToken, String secretKey, String claim) {
2 return JWT.require(Algorithm.HMAC512(secretKey)).build()
3 .verify(jwtToken)
4 .getClaim(claim)
5 .asString();
6}- JwtToken을 받으면 secretKey를 사용해서 지정된 claim을 반환한다
extractBearer #
1public String extractBearer(String jwtHeader) {
2 int pos = jwtHeader.lastIndexOf(" ");
3 return jwtHeader.substring(pos + 1);
4}- Authentication 해더의 Jwt Token은 앞에 “Bearer “가 붙기 때문에 “Bearer “를 제거하고 뒤의 순수한 Jwt Token만을 추출한다.
JwtProperties #
1public interface JwtProperties {
2 String SECRET = (JWT 암호화시 사용할 SecretKey);
3 int EXPIRATION_TIME = 60000 * 60;
4 String TOKEN_PREFIX = "Bearer";
5 String HEADER_STRING = "Authorization";
6}- JWT와 관련된 설정 수치들을 지정한 인터페이스
- JWT 암호화 시 사용되는 SecretKey의 값이 있기 때문에 gitIgnore 설정
JwtAuthenticationFilter #
1@RequiredArgsConstructor
2public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
3 private final AuthenticationManager authenticationManager;
4 private final JwtProcessor jwtProcessor;
5
6 @SneakyThrows
7 @Override
8 public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
9 throws AuthenticationException {
10 ObjectMapper objectMapper = new ObjectMapper();
11 Account account = objectMapper.readValue(request.getInputStream(), Account.class);
12
13 UsernamePasswordAuthenticationToken authenticationToken =
14 new UsernamePasswordAuthenticationToken(account.getUsername(), account.getPassword());
15
16 Authentication authentication = authenticationManager.authenticate(authenticationToken);
17 return authentication;
18 }
19
20 @Override
21 protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
22 Authentication authResult) throws IOException, ServletException {
23 UserAccount userAccount = (UserAccount) authResult.getPrincipal();
24
25 String jwtToken = jwtProcessor.createAuthJwtToken(userAccount);
26
27 response.addHeader(JwtProperties.HEADER_STRING, JwtProperties.TOKEN_PREFIX + " " + jwtToken);
28 }
29}- JWT로 인증을 하기 위한 클래스
- Spring Security 로그인 시 인증을 담당하는 UsernamePasswordAuthenticationFilter를 상속받는다.
Spring Bean으로 등록하지 않는 이유는 해당 클래스가 AuthenticationManager를 의존성 주입받는데 해당 클래스를 사용하는 SecurityConfig에서 AuthenticationManager를 빈으로 등록하기 때문에 순환 참조가 발생하기 때문이다. {: .prompt-info }
attemptAuthentication #
1@SneakyThrows
2@Override
3public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
4 throws AuthenticationException {
5 ObjectMapper objectMapper = new ObjectMapper();
6 Account account = objectMapper.readValue(request.getInputStream(), Account.class);
7
8 UsernamePasswordAuthenticationToken authenticationToken =
9 new UsernamePasswordAuthenticationToken(account.getUsername(), account.getPassword());
10
11 Authentication authentication = authenticationManager.authenticate(authenticationToken);
12 return authentication;
13}- 로그인 시 인증을 위해 실행되는 Method
- 오버라이드 해서 Json으로 들어오는 id와 password로 인증을 하도록 변경한다.
- 반환 값은 인증된 Authentication 객체이다.
successfulAuthentication #
1@Override
2protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
3 Authentication authResult) throws IOException, ServletException {
4 UserAccount userAccount = (UserAccount) authResult.getPrincipal();
5
6 String jwtToken = jwtProcessor.createAuthJwtToken(userAccount);
7
8 response.addHeader(JwtProperties.HEADER_STRING, JwtProperties.TOKEN_PREFIX + " " + jwtToken);
9}- 인증에 성공할 시 실행되는 Method
- 인증에 성공할 시 인증된 Account의 정보를 통해 JWT Token을 만들고 헤더(Authentication 헤더)에 포함시킨다.
JwtAuthorizationFilter #
1public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
2
3 private final AccountRepository accountRepository;
4 private final JwtProcessor jwtProcessor;
5
6 public JwtAuthorizationFilter(AuthenticationManager authenticationManager, AccountRepository accountRepository,
7 JwtProcessor jwtProcessor) {
8 super(authenticationManager);
9 this.accountRepository = accountRepository;
10 this.jwtProcessor = jwtProcessor;
11 }
12
13 @Override
14 protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
15 throws IOException, ServletException {
16 String jwtHeader = request.getHeader(JwtProperties.HEADER_STRING);
17
18 if (jwtHeader == null || !jwtHeader.startsWith(JwtProperties.TOKEN_PREFIX)) {
19 chain.doFilter(request, response);
20 return;
21 }
22
23 String jwtToken = jwtProcessor.extractBearer(jwtHeader);
24 String username = jwtProcessor.decodeJwtToken(jwtToken, JwtProperties.SECRET, "username");
25
26 if (username != null) {
27 Account account = accountRepository.findByUsername(username)
28 .orElseThrow(() -> new NonExistResourceException("해당 username을 갖는 Account를 찾을 수 없습니다."));
29
30 UserAccount userAccount = new UserAccount(account);
31 Authentication authentication = new UsernamePasswordAuthenticationToken(userAccount, null,
32 userAccount.getAuthorities());
33
34 SecurityContextHolder.getContext().setAuthentication(authentication);
35 }
36 chain.doFilter(request, response);
37 }
38}- JWT로 인가를 하기 위한 클래스
- 헤더를 통한 인증 시 적용되는 BasicAuthenticationFilter를 상속받는다.
- BasicAuthenticationFilter는 AuthenticationManager를 사용하기 때문에 super를 사용해서 주입해준다.
doFilterInternal #
1@Override
2protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
3 throws IOException, ServletException {
4 String jwtHeader = request.getHeader(JwtProperties.HEADER_STRING);
5
6 if (jwtHeader == null || !jwtHeader.startsWith(JwtProperties.TOKEN_PREFIX)) {
7 chain.doFilter(request, response);
8 return;
9 }
10
11 String jwtToken = jwtProcessor.extractBearer(jwtHeader);
12 String username = jwtProcessor.decodeJwtToken(jwtToken, JwtProperties.SECRET, "username");
13
14 if (username != null) {
15 Account account = accountRepository.findByUsername(username)
16 .orElseThrow(() -> new NonExistResourceException("해당 username을 갖는 Account를 찾을 수 없습니다."));
17
18 UserAccount userAccount = new UserAccount(account);
19 Authentication authentication = new UsernamePasswordAuthenticationToken(userAccount, null,
20 userAccount.getAuthorities());
21
22 SecurityContextHolder.getContext().setAuthentication(authentication);
23 }
24 chain.doFilter(request, response);
25}- 필터 적용 시 실행되는 Method.
- 헤더에 담겨있는 JWT Token을 디코딩해서 얻은 username값이 올바른지 판단하고 username으로 DB에서 Account를 찾아온다.
- 찾아진 Account로 만든 Authentication 객체를 SecurityContextHolder에 넣어서 인가를 처리한다.
Advertisement