728x90
목차
1. 기본 설정
■ Securityconfig
a. 보안 설정
- WebSecurityConfigurerAdapter를 상속 받아서 사용자 정의 보안 구성을 작성 ➡ 보안 설정을 하는 것
- @EnalbeWebSecurity 어노테이션을 사용해서 Spring Security를 활성화
- @Configuration 어노테이션을 적용하여 모든 요청을 거칠 때 해당 클래스를 거치게 됨
- BCrypt를 통해 암호화 한 것을 해석할 수 있는 passwordEncoder() 메소드를 @Bean 어노테이션을 붙여서 IOC에 등록
- BCryptPasswordEncoder는 라이브러리에 등록되어 있어 @Component를 적용할 수 없기 때문에 config에서 적용
- configure 메소드는 HTTP 요청에 대한 보안 구성을 정의
- http.csrf().disable() 을 사용해서 CSRF 보호 기능을 비활성화
- authorizeRequests()를 사용해서 요청에 대한 인증 및 권한 구성
- antMatchers에 로그인과 회원가입 요청uri를 넣어서 해당 요청들은 인증되지 않아도 접근 가능
- anyRequest().authenticated()를 사용해서 모든 요청에 인증이 되어야만 요청이 가능
b. JWT 토큰 요청 설정
- JWT를 통한 사용자 정의 인증을 위해 HttpBasic의 웹 기본 인증방식과 formLogin의 폼태그를 통한 로그인 방식을 비활성화
- 해당 코드는 session을 사용하지 않기 때문에 세션 비활성화
- /courses에 관한 요청들은 ROLE_ADMIN 권한이 잇는 사용자만 요청을 보낼 수 있음
- ROLE_ 접두어는 hasRole메소드를 통해 자동으로 적용
- 나머지 모든 요청들은 authenticate에 사용자 정보가 있어야 인증됨
- addFilterBefore()를 통해 해당 필터를 거치기 전에 JwtAuthenticationFilter(사용자 인증 필터)를 거치도록 함
- 사용자 인증 필터에서 JWT 토큰이 유효한지 테스트
- 에러 발생시 JwtAuthenticationEntryPoint로 이동
package com.web.study.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import com.web.study.security.jwt.JwtAuthenticationEntryPoint;
import com.web.study.security.jwt.JwtAuthenticationFilter;
import com.web.study.security.jwt.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter{
private final JwtTokenProvider jwtTokenProvider;
private final JwtAuthenticationEntryPoint authenticationEntryPoint;
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// security filter
// controller 거치지 전에 무조건 적용
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable();
http.httpBasic().disable(); // 웹 기본 인증 방식
http.formLogin().disable(); // 폼태그를 통한 로그인
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); // 세션 비활성
http.authorizeRequests()
.antMatchers("/auth/register/**","/auth/login/**")
.permitAll()
.antMatchers("/courses")
.hasRole("ADMIN")
.anyRequest()
.authenticated()
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class)
.exceptionHandling()
.authenticationEntryPoint(authenticationEntryPoint);
}
}
2. 회원가입
- 회원가입할 정보를 입력
- 해당 정보로 회원가입 Post 요청
- AuthController에서 요청 정보를 받아옴
- username 중복 확인(중복 시 advice로 가서 예외 처리)
- AuthService에서 Dto를 Entity로 변환할 때 password 암호화
- UserRepository에서 DB에 접근하여 회원가입 정보를 DB에 저장
■ RegisteUserReqDto
- 회원가입 요청을 받을 때 요청받을 정보(username, password, name, email)를 멤버 변수로 받음
- AuthService에서 Dto를 Entity로 변환할 메소드를 생성하고 password는 암호화하여 전달
package com.web.study.dto.auth;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import com.web.study.domain.entity.User;
import lombok.Data;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Data
@ToString
public class RegisteUserReqDto {
private String username;
private String password;
private String name;
private String email;
public User toEntity() {
return User.builder()
.username(username)
.password(new BCryptPasswordEncoder().encode(password))
.name(name)
.email(email)
.build();
}
}
■ AuthController
- /auth/register로 Post 요청을 보내 회원가입 진행
- authService에서 duplicatedUsername을 해서 아이디 중복체크 진행
- 아이디 중복확인 후 registerUser가 실행되서 user 객체 생성
package com.web.study.controller.auth;
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.RestController;
import com.web.study.dto.DataResponseDto;
import com.web.study.dto.ResponseDto;
import com.web.study.dto.auth.LoginReqDto;
import com.web.study.dto.auth.RegisteUserReqDto;
import com.web.study.service.AuthService;
import lombok.RequiredArgsConstructor;
@RestController
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping("/auth/register")
public ResponseEntity<? extends ResponseDto> registe(@RequestBody RegisteUserReqDto registeUserReqDto) {
authService.duplicatedUsername(registeUserReqDto);
authService.registeUser(registeUserReqDto);
return ResponseEntity.ok().body(ResponseDto.ofDefault());
}
@PostMapping("/auth/login")
public ResponseEntity<? extends ResponseDto> login(@RequestBody LoginReqDto loginReqDto) {
return ResponseEntity.ok().body(DataResponseDto.of(authService.login(loginReqDto)));
}
}
■ AuthService
- Dto에서 값을 받아와서 중복체크와 회원가입 실행
package com.web.study.service;
import com.web.study.dto.auth.LoginReqDto;
import com.web.study.dto.auth.RegisteUserReqDto;
import com.web.study.dto.response.auth.JwtTokenRespDto;
public interface AuthService {
public void registeUser(RegisteUserReqDto registeUserReqDto);
public void duplicatedUsername(RegisteUserReqDto registeUserReqDto);
public JwtTokenRespDto login(LoginReqDto loginReqDto);
}
■ AuthServiceImpl
- duplicatedUsername 메소드는 중복되는 아이디가 있는지 확인하는 메소드
- userRepository에서 findUserByUsername을 사용해서 중복되는 username이 있으면 error 반환
- 중복되는 username이 없으면 계속 진행
- registeUser 메소드는 회원가입을 진행하는 유저 정보를 등록해주는 메소드
- userRepository에서 saveUser를 사용해서 유저 등록
- authority 정보를 저장할 수 있는 리스트 생성
- 생성된 user_id를 받아와서 해당 user_id에 1번(ROLE_USER) 권한을 부여한 authority를 하나 생성하여 리스트에 추가
- addAuthorities에 해당 리스트를 넣어서 권한을 추가
- user를 추가한 뒤 추가한 user에 ROLE_USER 권한을 부여하는 코드를 작성한 예시
package com.web.study.service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
import com.web.study.domain.entity.Authority;
import com.web.study.domain.entity.User;
import com.web.study.dto.auth.LoginReqDto;
import com.web.study.dto.auth.RegisteUserReqDto;
import com.web.study.dto.response.auth.JwtTokenRespDto;
import com.web.study.exception.CustomException;
import com.web.study.repository.UserRepository;
import com.web.study.security.jwt.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class AuthServiceImpl implements AuthService{
private final UserRepository userRepository;
private final AuthenticationManagerBuilder authenticationManagerBuilder;
private final JwtTokenProvider jwtTokenProvider;
@Override
public void registeUser(RegisteUserReqDto registeUserReqDto) {
User userEntity = registeUserReqDto.toEntity();
userRepository.saveUser(userEntity);
List<Authority> authorities = new ArrayList<>();
authorities.add(Authority.builder().user_id(userEntity.getUser_id()).role_id(1).build());
userRepository.addAuthorities(authorities);
}
@Override
public void duplicatedUsername(RegisteUserReqDto registeUserReqDto) {
User userEntity = userRepository.findUserByUsername(registeUserReqDto.getUsername());
if(userEntity != null) {
Map<String, String> errorMap = new HashMap<>();
errorMap.put("username", "이미 사용중인 사용자이름입니다.");
throw new CustomException("중복 검사 오류", errorMap);
}
}
@Override
public JwtTokenRespDto login(LoginReqDto loginReqDto) {
// security가 알아볼수 있는 형태로 username과 password를 변환(매니저가 알아먹을수 있는 형태)
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginReqDto.getUsername(), loginReqDto.getPassword());
// 인증이 완료가 되면 authentication 객체를 생성
// 실제 로그인 정보를 authentication안에서 관리
// UserDetailsService의 loadUserByUsername()이 호출이 된다!!!
// authentication에 Principal로 업 캐스팅 되어 적용
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
return jwtTokenProvider.createToken(authentication);
}
}
■ UserRepository
- saveUser는 회원가입 유저 추가 메소드
- addAuthorities는 권한 부여 메소드
- findUserByUsername은 유저 정보 중복 확인 메소드
package com.web.study.repository;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import com.web.study.domain.entity.Authority;
import com.web.study.domain.entity.User;
@Mapper
public interface UserRepository {
public int saveUser(User user);
public int addAuthorities(List<Authority> authorities);
public User findUserByUsername(String username);
}
■ User.xml
- Role, Authority, User의 resultMap 생성
- saveUser에서 매개변수로 받은 값들을 insert
- 데이터베이스에서 auto increment로 생성된 user_id를 받아오기 위해 useGeneratedKeys를 true로 적용
- KeyProperty에 user_id를 넣어줘서 user_id에 키값을 넣어줌
- 해당 키워드를 통해 service에서 권한을 추가할 때 해당 user_id에 권한 추가 가능
- addAuthorities에서는 권한 부여 리스트를 받아와서 insert
- foreach를 사용하여 매개변수로 받은 리스트를 반복을 돌려 꺼내 separator인 쉼표로 구분하여 순서대로 나열
- 하나씩 꺼내진 권한들을 데이터베이스에 등록
- findUserByUsername을 사용해서 같은 username이 있으면 해당 값을 반환
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.web.study.repository.UserRepository">
<resultMap type="com.web.study.domain.entity.Role" id="role">
<result column="role_id" property="role_id"/>
<result column="role_name" property="role_name"/>
</resultMap>
<resultMap type="com.web.study.domain.entity.Authority" id="authority">
<result column="authority_id" property="authority_id"/>
<result column="user_id" property="user_id"/>
<result column="role_id" property="role_id"/>
<collection property="role" resultMap="role"/>
</resultMap>
<resultMap type="com.web.study.domain.entity.User" id="user">
<result column="user_id" property="user_id"/>
<result column="username" property="username"/>
<result column="password" property="password"/>
<result column="name" property="name"/>
<result column="email" property="email"/>
<collection property="authorities" javaType="list" resultMap="authority"></collection>
</resultMap>
<insert
id="saveUser"
parameterType="com.web.study.domain.entity.User"
useGeneratedKeys="true"
keyProperty="user_id">
insert into user_mst
values(0, #{username}, #{password}, #{name}, #{email})
</insert>
<insert id="addAuthorities" parameterType="list">
insert into authority_mst
values
<foreach collection="list" item="authority" separator=",">
(0, #{authority.user_id}, #{authority.role_id})
</foreach>
</insert>
<select id="findUserByUsername" resultMap="user">
select
um.user_id,
um.username,
um.password,
um.name,
um.email,
am.authority_id,
am.role_id,
rm.role_name
from
user_mst um
left outer join authority_mst am on(am.user_id = um.user_id)
left outer join role_mst rm on(rm.role_id = am.role_id)
where
um.username = #{username}
</select>
</mapper>
3. 로그인
- 로그인할 정보를 입력(username, password)
- 해당 정보로 로그인 요청
- AuthenticationManager에게 username, password를 전달
- AuthenticationManager가 인증을 시작
- UserDetailsService의 loadUserByUsername(String username)이 호출
- UserDetails를 리턴받아서 Authentication객체를 생성(username으로 DB에서 조회된 UserEntity가 없으면 예외 처리)
- Authentication 객체가 생성되면 로그인 성공(Security에서 인증 절차)
- 토큰을 제공하기위해 Authentication객체를 JWT로 변환하는 작업을 진행
- 변환된 JWT를 클라이언트에게 응답
- 클라이언트는 JWT 토큰을 로컬스토리지나 쿠키에 저장
■ PrincipalUserDetails
- PrincipalUserDetails 클래스는 인증이 완료된 유저의 정보를 모아두는 객체
- UserDetails를 구현하는 클래스
- 사용기간 만료, 계정 잠금, 비밀번호 5회 틀림, 계정 비활성화 등을 설정 가능
package com.web.study.security;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import lombok.Builder;
import lombok.Getter;
// security 라이브러리가 구현해줌
@Builder
@Getter
public class PrincipalUserDetails implements UserDetails{
private static final long serialVersionUID = -1913596722176708482L;
private int userId;
private String username;
private String password;
private List<String> roles;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
roles.forEach(role -> {
authorities.add(new SimpleGrantedAuthority(role));
});
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
// 사용기간 만료
@Override
public boolean isAccountNonExpired() {
return true;
}
// 계정을 잠궈버림
@Override
public boolean isAccountNonLocked() {
return true;
}
// 비밀번호 5회 틀렸을 때
@Override
public boolean isCredentialsNonExpired() {
return true;
}
// 계정 비활성 상태(이메일 인증을 완료해야하거나, 전화번호 인증을 하지 않았을 때)
@Override
public boolean isEnabled() {
return true;
}
}
■ PrincipalDetailsService
- UserDetailsService를 구현하는 클래스
- loadUserByUsername을 사용해서 로그인한 정보가 데이터베이스에 존재하는지 확인
- 인증 성공 시 인증된 사용자를 리턴 값으로 제공
package com.web.study.security;
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 com.web.study.domain.entity.User;
import com.web.study.exception.CustomException;
import com.web.study.repository.UserRepository;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User userEntity = userRepository.findUserByUsername(username);
if(userEntity == null) {
throw new CustomException("사용자 정보를 다시 확인해주세요.");
}
return userEntity.toPrincipal();
}
}
■ LoginReqDto
- 로그인 시 username과 password 값을 받음
package com.web.study.dto.auth;
import lombok.Data;
@Data
public class LoginReqDto {
private String username;
private String password;
}
■ AuthController
- 로그인 요청은 /auth/login로 Post 요청으로 로그인 진행
- 로그인은 등록하는 것이 아니지만 정보들이 url에 노출되면 안되기 때문에 Post 요청으로 진행
package com.web.study.controller.auth;
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.RestController;
import com.web.study.dto.DataResponseDto;
import com.web.study.dto.ResponseDto;
import com.web.study.dto.auth.LoginReqDto;
import com.web.study.dto.auth.RegisteUserReqDto;
import com.web.study.service.AuthService;
import lombok.RequiredArgsConstructor;
@RestController
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping("/auth/register")
public ResponseEntity<? extends ResponseDto> registe(@RequestBody RegisteUserReqDto registeUserReqDto) {
authService.duplicatedUsername(registeUserReqDto);
authService.registeUser(registeUserReqDto);
return ResponseEntity.ok().body(ResponseDto.ofDefault());
}
@PostMapping("/auth/login")
public ResponseEntity<? extends ResponseDto> login(@RequestBody LoginReqDto loginReqDto) {
return ResponseEntity.ok().body(DataResponseDto.of(authService.login(loginReqDto)));
}
}
■ AuthService
package com.web.study.service;
import com.web.study.dto.auth.LoginReqDto;
import com.web.study.dto.auth.RegisteUserReqDto;
import com.web.study.dto.response.auth.JwtTokenRespDto;
public interface AuthService {
public void registeUser(RegisteUserReqDto registeUserReqDto);
public void duplicatedUsername(RegisteUserReqDto registeUserReqDto);
public JwtTokenRespDto login(LoginReqDto loginReqDto);
}
■ AuthServiceImpl
- authenticationManagerBuilder는 로그인하는 사용자 정보를 가지고 인증을 대신해주는 매니저 역할
- 해당 매니저가 UsernamePasswordAuthenticationToken을 통해 매개변수를 자기가 알아볼 수 있도록 변환
- authenticate 메소드를 통해 사용자를 인증
- 매개변수로 authenticationToken 값을 넣어줌
- 실행 시 자동으로 UserDetailsService에 loadUserByUsername이 실행
- 여기서 리턴 받은 authentication(인증된 사용자 정보)을 통해 jwt 토큰 생성
package com.web.study.service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
import com.web.study.domain.entity.Authority;
import com.web.study.domain.entity.User;
import com.web.study.dto.auth.LoginReqDto;
import com.web.study.dto.auth.RegisteUserReqDto;
import com.web.study.dto.response.auth.JwtTokenRespDto;
import com.web.study.exception.CustomException;
import com.web.study.repository.UserRepository;
import com.web.study.security.jwt.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class AuthServiceImpl implements AuthService{
private final UserRepository userRepository;
private final AuthenticationManagerBuilder authenticationManagerBuilder;
private final JwtTokenProvider jwtTokenProvider;
@Override
public void registeUser(RegisteUserReqDto registeUserReqDto) {
User userEntity = registeUserReqDto.toEntity();
userRepository.saveUser(userEntity);
List<Authority> authorities = new ArrayList<>();
authorities.add(Authority.builder().user_id(userEntity.getUser_id()).role_id(1).build());
userRepository.addAuthorities(authorities);
}
@Override
public void duplicatedUsername(RegisteUserReqDto registeUserReqDto) {
User userEntity = userRepository.findUserByUsername(registeUserReqDto.getUsername());
if(userEntity != null) {
Map<String, String> errorMap = new HashMap<>();
errorMap.put("username", "이미 사용중인 사용자이름입니다.");
throw new CustomException("중복 검사 오류", errorMap);
}
}
@Override
public JwtTokenRespDto login(LoginReqDto loginReqDto) {
// security가 알아볼수 있는 형태로 username과 password를 변환(매니저가 알아먹을수 있는 형태)
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginReqDto.getUsername(), loginReqDto.getPassword());
// 인증이 완료가 되면 authentication 객체를 생성
// 실제 로그인 정보를 authentication안에서 관리
// UserDetailsService의 loadUserByUsername()이 호출이 된다!!!
// authentication에 Principal로 업 캐스팅 되어 적용
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
return jwtTokenProvider.createToken(authentication);
}
}
■ JwtTokenProvider
- 생성자에 @Value 어노테이션을 사용해서 yml에 작성한 secretKey값을 매개변수로 받아와 암호화하여 key 멤버변수에 대입
- createToken 메소드를 사용하여 토큰 생성
- 매개변수로 받는 authentication은 인증된 사용자의 정보를 의미
- authentication에서 받은 권한들을 ,를 사용해서 하나의 문자열로 만들어줌
- tokenExpiresDate변수로 토큰 만료시간 생성
- authentication에서 getPrincipal을 사용하여 userDetails 변수에 대입
- Jwts.builder()를 통해 토큰 생성
- setSubject에는 토큰의 이름을 지정하며 해당 코드에서는 username으로 지정
- claim에는 userId와 auth에 해당하는 값을 해당 유저의 userId와 권한들로 지정
- setExpiration에는 위에서 생성한 토큰 만료시간을 지정
- signWith에서는 key값을 HS256으로 암호화 진행
- compact()를 통해 토큰 생성
- 리턴 값에는 생성한 토큰의 앞에 Bearer를 앞에 붙여서 리턴
package com.web.study.security.jwt;
import java.security.Key;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
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 com.web.study.dto.response.auth.JwtTokenRespDto;
import com.web.study.exception.CustomException;
import com.web.study.security.PrincipalUserDetails;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SecurityException;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Component
public class JwtTokenProvider {
private final Key key;
// component일 때 객체가 생성될 때 application.yml에서 값을 가져올 수 있음
public JwtTokenProvider(@Value("${jwt.secretKey}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
public JwtTokenRespDto createToken(Authentication authentication) {
StringBuilder authoritiesBuilder = new StringBuilder();
authentication.getAuthorities().forEach(grantedAuthority -> {
authoritiesBuilder.append(grantedAuthority.getAuthority());
authoritiesBuilder.append(",");
});
authoritiesBuilder.delete(authoritiesBuilder.length() - 1 , authoritiesBuilder.length());
String authorities = authoritiesBuilder.toString();
long now = (new Date()).getTime();
// 1000 == 1초
Date tokenExpiresDate = new Date(now + (1000 * 60 * 30)); // 토큰 만료 시간
PrincipalUserDetails userDetails = (PrincipalUserDetails) authentication.getPrincipal();
String accessToken = Jwts.builder()
.setSubject(authentication.getName())
.claim("userId", userDetails.getUserId())
.claim("auth", authorities)
.setExpiration(tokenExpiresDate)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
return JwtTokenRespDto.builder()
.grantType("Bearer")
.accessToken(accessToken)
.build();
}
public boolean validateToken(String token) {
try {
// 토큰이 어떤 토큰인지 알려주는 라이브러리
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException e) {
// Security 라이브러리에 오류가 있거나, JOSN의 포맷이 잘못된 형식의 JWT가 들어왔을 때 예외
// SignatureExceptio9n이 포함되어 있음
log.info("Invalid JWT Token", e);
} catch (ExpiredJwtException e) {
// 토큰의 유효기간이 만료된 경우 예외
log.info("Expired JWT Token", e);
} catch (UnsupportedJwtException e) {
// jwt의 형식을 지키지 않은 경우 (Header.Payload.Signature)
log.info("Unsupported JWT Token", e);
} catch (IllegalArgumentException e) {
// JWT 토큰이 없을 때
log.info("IllegalArgument JWT Token", e);
} catch (Exception e) {
log.info("JWT Token Error", e);
}
return false;
}
public Authentication getAuthentication(String accessToken) {
Claims claims = parseClaims(accessToken);
Object roles = claims.get("auth");
if(roles == null) {
throw new CustomException("권한 정보가 없는 토큰입니다.");
}
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
String[] rolesArray = roles.toString().split(",");
Arrays.asList(rolesArray).forEach(role -> {
authorities.add(new SimpleGrantedAuthority(role));
});
UserDetails userDetails = new User(claims.getSubject(),"",authorities); // username, password, authorities
return new UsernamePasswordAuthenticationToken(userDetails,"",authorities);
}
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(accessToken)
.getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
■ JwtTokenRespDto
package com.web.study.dto.response.auth;
import lombok.Builder;
import lombok.Data;
@Builder
@Data
public class JwtTokenRespDto {
private String grantType;
private String accessToken;
}
4. 요청 토큰 인증(JWT 토큰)
- 요청 Header에 Bearer 방식으로 JWT 토큰을 전달
- Security에서 인증이 필요한 요청들은 JwtAuthenticationFilter를 통해 인증절차를 진행
- 인증의 최종목표는 SecurityContextHolder 객체의 Context에 Authentication 객체 등록(인증 성공)
- JwtAuthenticationFilter에서 요청 Header에 들어있는 Authorization의 JWT토큰을 추출
- JWT 토큰을 검증(검증 실패시 Exception이 생성되며 AuthenticationEntryPoint가 실행되면서 401응답)
- JWT 토큰 검증이 완료되면 JWT 토큰에서 Claims를 추출
- Claims에서 username과 Authorities를 추출하여 Authentication 객체를 생성
- 생성된 Authentication객체를 SecurityContext에 등록
- 등록이 완료되면 다음 doChain이 호출
■ JwtTokenProvider
- validateToken 메소드를 통해 요청 받은 토큰 값이 유효한지 테스트
- Jwts.parserBuilder를 사용하여 해당 토큰이 어떤 토큰인지 확인
- 토큰이 유효하면 true를 반환하고 예외 발생 시 아래 코드의 catch를 통해 예외 처리
- getAuthentication 메소드를 사용하여 검증된 토큰을 Authentication으로 변환
- accessToken을 매개변수로 받음
- parseClaims 메소드를 생성하여 토큰을 Claims 형태로 변환
- Claims에서 권한만 콤마를 기준으로 배열화한 뒤 리스트로 변환
- 권한이 없으면 권한 정보가 없는 토큰이라고 예외 처리
- User에 username, password, authorities를 넣어 생성 후 UserDetails에 대입(해당 코드는 password는 빈값)
- UsernamePasswordAuthenticationToken에 userDetails 객체, 인증 관련 값, 권한을 넣어서 생성 후 리턴
- 임시의 Authentication을 생성하여 리턴하고 필터에서 Authentication으로 업캐스팅 됨
package com.web.study.security.jwt;
import java.security.Key;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
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 com.web.study.dto.response.auth.JwtTokenRespDto;
import com.web.study.exception.CustomException;
import com.web.study.security.PrincipalUserDetails;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SecurityException;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Component
public class JwtTokenProvider {
private final Key key;
// component일 때 객체가 생성될 때 application.yml에서 값을 가져올 수 있음
public JwtTokenProvider(@Value("${jwt.secretKey}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
public JwtTokenRespDto createToken(Authentication authentication) {
StringBuilder authoritiesBuilder = new StringBuilder();
authentication.getAuthorities().forEach(grantedAuthority -> {
authoritiesBuilder.append(grantedAuthority.getAuthority());
authoritiesBuilder.append(",");
});
authoritiesBuilder.delete(authoritiesBuilder.length() - 1 , authoritiesBuilder.length());
String authorities = authoritiesBuilder.toString();
long now = (new Date()).getTime();
// 1000 == 1초
Date tokenExpiresDate = new Date(now + (1000 * 60 * 30)); // 토큰 만료 시간
PrincipalUserDetails userDetails = (PrincipalUserDetails) authentication.getPrincipal();
String accessToken = Jwts.builder()
.setSubject(authentication.getName())
.claim("userId", userDetails.getUserId())
.claim("auth", authorities)
.setExpiration(tokenExpiresDate)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
return JwtTokenRespDto.builder()
.grantType("Bearer")
.accessToken(accessToken)
.build();
}
public boolean validateToken(String token) {
try {
// 토큰이 어떤 토큰인지 알려주는 라이브러리
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException e) {
// Security 라이브러리에 오류가 있거나, JOSN의 포맷이 잘못된 형식의 JWT가 들어왔을 때 예외
// SignatureExceptio9n이 포함되어 있음
log.info("Invalid JWT Token", e);
} catch (ExpiredJwtException e) {
// 토큰의 유효기간이 만료된 경우 예외
log.info("Expired JWT Token", e);
} catch (UnsupportedJwtException e) {
// jwt의 형식을 지키지 않은 경우 (Header.Payload.Signature)
log.info("Unsupported JWT Token", e);
} catch (IllegalArgumentException e) {
// JWT 토큰이 없을 때
log.info("IllegalArgument JWT Token", e);
} catch (Exception e) {
log.info("JWT Token Error", e);
}
return false;
}
public Authentication getAuthentication(String accessToken) {
Claims claims = parseClaims(accessToken);
Object roles = claims.get("auth");
if(roles == null) {
throw new CustomException("권한 정보가 없는 토큰입니다.");
}
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
String[] rolesArray = roles.toString().split(",");
Arrays.asList(rolesArray).forEach(role -> {
authorities.add(new SimpleGrantedAuthority(role));
});
UserDetails userDetails = new User(claims.getSubject(),"",authorities); // username, password, authorities
return new UsernamePasswordAuthenticationToken(userDetails,"",authorities);
}
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(accessToken)
.getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
■ JwtAuthenticationFilter
- GenericFilterBean클래스를 상속
- JWT 토큰이 유효한지 체크하기 위한 필터
- doFilter 메소드에 전처리만 적용
- 요청 시 받은 토큰을 token변수를 생성하여 대입
- getToken을 통하여 Bearer를 제거한 토큰 값을 가져옴
- 해당 토큰이 알맞는지 확인하기 위해 validateToken메소드에 해당 토큰을 매개변수로 적용
- 유효할 경우 getAuthentication 메소드를 통해 토큰으로 Authentication 객체 생성
- SecurityContextHolder에 Context 내에 Authentication 값이 들어가게 되면 인증 완료
package com.web.study.security.jwt;
import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
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 lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean{
private final JwtTokenProvider jwtTokenProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
String token = getToken(request);
System.out.println("token: " + token);
boolean validationFlag = jwtTokenProvider.validateToken(token);
System.out.println("flag: " + validationFlag);;
if(validationFlag) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
// SecurityContext에 Authentication객체가 들어있으면 로그인 인증된것
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
private String getToken(ServletRequest request) {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String type = "Bearer";
String token = httpServletRequest.getHeader("Authorization");
// hasText 문자열이 Null, 공백이 아닌지 확인
if(StringUtils.hasText(token) && token.startsWith(type)) {
return token.substring(type.length() +1);
}
return null;
}
}
■ JwtAuthenticationEntryPoint
- AuthenticationEntryPoint를 구현하는 클래스
- 자원에 대한 요청이 인증되지 않을 경우 commence() 메소드가 호출되어 인증 실패에 대한 에러 처리를 함
- response에서 setContentType을 JSON형태로 지정 후 상태를 401로 설정 후 PrintWriter에 대입
- ErrorResponseDto를 통해 해당 오류 정보를 출력
package com.web.study.security.jwt;
import java.io.IOException;
import java.io.PrintWriter;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.web.study.dto.ErrorResponseDto;
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint{
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpStatus.UNAUTHORIZED.value());
PrintWriter out = response.getWriter();
ObjectMapper responseJson = new ObjectMapper();
out.println(responseJson.writeValueAsString(ErrorResponseDto.of(HttpStatus.UNAUTHORIZED, authException)));
}
}