개요
로그인 로직은 다음과 같습니다.
1. /login으로 username, password를 받아서 POST요청이 들어옵니다.
2. UsernamePasswordAuthenticationFilter를 통해 로그인 요청을 받습니다. 사용자가 제공한 사용자 이름 및 비밀번호로부터 Authentication 객체를 생성하고, 이를 통해 AuthenticationManager로 전달합니다.
3. AthenticationManager에서는, UsernamePasswordAuthenticationFilter로부터 전달된 Authentication 객체를 처리하고, 실제로 사용자를 인증합니다.
4. UserDetailsService에서는 loadUserByUsername 메서드를 구현하여 사용자 이름을 기반으로 사용자의 상세 정보를 검색합니다. 여기서 Repository로 DB로 접근을 해서 해당 유저에 대한 데이터를 검색, 가져옵니다.
5. UserDetails에서는 UserDetailsService에서 받은 UserEntity 객체에서 권한 정보를 추출하여 GrantedAuthority 형식으로 반환하는 역할을 합니다. Spring Security는 권한을 GrantedAuthority 형식으로 처리하며, 이를 통해 로그인 사용자의 권한을 관리하고 제어할 수 있습니다.
6. 사용자가 성공적으로 인증되었을 때 호출되는 메서드로, JWT 토큰을 생성하고 응답에 추가하는 역할을 합니다.
UsernamePasswordAuthenticationFilter
@RequiredArgsConstructor
public class LoginFilter extends UsernamePasswordAuthenticationFilter{
private final AuthenticationManager authenticationManager;
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException{
String username = obtainUsername(request);
String password = obtainPassword(request);
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password, null);
return authenticationManager.authenticate(authToken);
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication){
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed){
}
}
UsernamePasswordAuthenticationFilter를 상속받는 LoginFilter를 만듭니다.
여기에는 attemptAuthentication라는 기본 메서드가 존재합니다. 리턴타입은 인증을 하는 Authentication을 리턴하고, 인자는 응답과 요청을 받습니다. 예외처리를 위해 throws AuthentiationException을 해줍니다.
..
login을 통해 받은 request에서 username, password를 추출해서 꺼내고, 인증을 진행하는데, SpringSecurity에서는 사용자 인증을 위해 UsernamePasswordAuthenticationToken형식으로 username, password를 authenticationManager에 던져줘서 인증을 진행합니다.
UserDetailsService
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserEntity userData = userRepository.findByUsername(username);
if(userData != null){
return new CustomUserDetails(userData);
}
return null;
}
}
UserDetailsService 인터페이스를 구현하는 CustomUserDetailsService를 만들어줍니다.
loadUserByUsername이라는 메서드를 Override해서 usename을 기반으로 검색하고, 데이터를 가져와줍니다. 해당 데이터가 존재하지 않는다면 null을 반환, 존재한다면 CustomUserDetails에 userData를 던쳐주게 됩니다.
UserDetails
@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {
private final UserEntity userEntity;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collection = new ArrayList<>();
collection.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return userEntity.getRole();
}
});
return collection;
}
@Override
public String getPassword() {
return userEntity.getPassword();
}
@Override
public String getUsername() {
return userEntity.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
UserDetails를 구현하는 CustomUserDetails를 만들어줍니다. 이 클래스는 주어진 UserEntity를 기반으로 사용자 세부 정보를 나타냅니다. UserEntity 객체에서 권한 정보를 추출하여 GrantedAuthority 형식으로 반환하는 역할을 합니다. Spring Security는 권한을 GrantedAuthority 형식으로 처리하며, 이를 통해 로그인 사용자의 권한을 관리하고 제어할 수 있습니다.
SecurityConfig에 필터 적용
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final AuthenticationConfiguration authenticationConfiguration; // Manager에서 인자로 사용하기 위해 추가
@Bean // 추가 Manager반환 LoginFilter에서 쓰기위해
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception
{
return configuration.getAuthenticationManager();
}
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
http
.csrf((auth) -> auth
.disable());
http
.formLogin((auth) -> auth
.disable());
http
.httpBasic((auth) -> auth
.disable());
http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/login", "/", "/join").permitAll()
.requestMatchers("/admin").hasRole("ADMIN")
.anyRequest().authenticated());
http // 필터 추가
.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration)), UsernamePasswordAuthenticationFilter.class);
http
.sessionManagement((session) -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
}
SecurityConfig에 addFilter를 통해 LoginFilter를 추가해줍니다. http.addFilter로 필터를 등록하는데, addFilterAt은 그자리에 등록, Before는 필터 이전에, After는 이후에 등록합니다. 우리는 usernameAuthenticaiotnFilter에 우리가 작성한 LoginFilter로 대체할 것이므로 addFilterAt으로 해줍니다.
LoginFilter에서 authenticationManager를 주입해서 사용했고, authenticationManager에서는 authenticationConfiguration라는 인자를 받았습니다. 이를 위해 authenticationManager를 Bean으로 등록해서 사용해줍니다.
JWT 토큰 발행
JWT는 "adj232p3.21312.3ds233231da" 와 같이 문자열 형태를 띄고 있습니다.
중간에 점 2개가 있는데 이걸로 header, payload, signatual로 구분해서 내부로 정보를 가져옵니다.
- header : JWT라고 토큰을 명시, 사용된 암호화 알고리즘 명시
- payload : 사용의 실제정보(body정보 > 토큰발급일자, role, username 등등)
- signature : 토큰을 특정 발생서버에서 확인하도록 base64방식으로 header,payload를 encode 합니다. secretKey를 활용해서 암호화를 진행합니다.
JWT는 base64로 인코딩만하기 때문에 외부에서 디코딩을 할 수 있습니다. 한마디로 유출되도 되는 내용만 담아야합니다. 유저인지 usernam 등등.. 토큰자체의 발급처를 알기위해서만 사용하는 용도입니다.
만약 외부에서 payload를 변경해서 일반유저를 admin권한을 줘버려도, signature에서 암호화 알고리즘이 다르기 때문에 토큰이 반려됩니다. signatue를 통해 외부에서 보이기만하고, 바꾸는건 하지 못합니다.
spring:
jwt:
secret: lsakjadkldnqiowjp2131p23j12p512oep122pop231pj1p2j2p31o3po12omd
암호화를 위해 sercretKey를 만들어둡니다. Service나 클래스 내에 만들어 놓으면 보안상 좋지 않습니다.(유출 등등...)그러므로 application.yml에 sercret이라는 이름으로 아무런 내용으로 secretKey를 만들어 놓습니다.
토큰 생성
@Component
public class JwtUtil {
private SecretKey secretKey;
public JwtUtil(@Value("${spring.jwt.secret}")String secret) { // 생성자에 secretKey 넣기
this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
}
public String getUsername(String token) { // 유저 확인
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("username", String.class);
}
public String getRole(String token) { // role 확인
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class);
}
public Boolean isExpired(String token) { // 만료일 확인
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());
}
public String createJwts(String username, String role, long expiredMs) { // 토큰생성
return Jwts.builder()
.claim("username", username)
.claim("role", role)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expiredMs))
.signWith(secretKey)
.compact();
}
}
JWTUtil라는 클래스를 만듭니다.@Value어노테이션을 통해서 application.yml에 만들어놓은 secretKey를 가져오는데, 이 키를 이용해서 생성자에서 secretKey를 초기화하게 됩니다. 이 키를 이용해서 JWT를 생성,검증합니다.
내부의 메서드를 구현합니다. 검증할 메서드 3개, 생성할 1개를 만들어주겠습니다. 검증용 메서드들은 토큰을 받아서 jwts.parser로 내부데이터를 확인해서 꺼내줍니다. 토큰생성 메서드는 로그인 성공 시, 토큰을 생성해서 응답해줍니다.
getUsrename
token을 전달받아서 jwts.parser로 암호화된 토큰이 되어있으니 verifywith으로 secretKey를 넣어서 우리가 생성한 키가 맞는지 확인합니다. build로 리턴해준 뒤, parseSignedClaims로 claim을 확인하고 payload에서 데이터를 .get으로 가져올 수 있습니다. 획득할 데이터는 "username"이라는 key, 형식은 String을 가지고 있는 username을 가져옵니다.
getRole
getUsername과 거의 동일합니다.
isExpired
boolean값으로 소멸인지 아닌지 판단한다.
여기서는 getExpiration으로 before를 선언해준뒤 내부의 현재시간을 입력해주면 확인할 수 있습니다.
토큰 생성 메서드
String형식으로 리턴면서, username, role, expiredMs(토큰 유효시간)를 인자로 받습니다. 리턴으로 jwts.builder로 토큰을 만듭니다.
내부에 claim을 선언하고 데이터를 넣어줄 수 있습니다. 이렇게하면 payload영역에 username,role이라는 데이터를 넣습니다. issuedAt에는 발행시간을 넣고, expiration에는 현재시간 + 유효시간을더해서 계산해서 넣습니다. secretKey를 넣어서 signature로 암호화를 한다. 그리고 compact로 토큰을 compact해서 리턴시켜주면 됩니다.
jwtUtil 적용
http
.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil), UsernamePasswordAuthenticationFilter.class); //jwtutil추가
LoginFilter에 jwtUtil이라는 인자가 추가되었기 때문에, 위처럼 Security Config에 LoginFilter를 추가하는 부분에 jwtUtil추가해줍니다.
...
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication){
CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();
String username = customUserDetails.getUsername();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
GrantedAuthority auth = iterator.next();
String role = auth.getAuthority();
String token = jwtUtil.createJwts(username, role, 60*60*10L);
response.addHeader("Authorization", "Bearer" + token);
}
...
LoginFilter에 successfulAuthentication메서드를 작성합니다. 해당 메서드는 로그인이 성공시 동작하는 메서드입니다. userDetails에서 username을 가져오고, role값을 뽑아내서 jwUtil에서 토큰을 생성하는 createJwts를 실행하도록 합니다. 여기에 username, role을 담고, 토큰 유효시간을 적어넣습니다. 60 * 60 * 10L로 10분으로 설정해줍니다.
만든 jwt토큰을 response에 Header에 담아서 리턴해줍니다. 데이터는 "Bearer"라는 인증방식을 접두사로 붙이고 띄어쓰기를 사용합니다. http인증방식중에 rf7235정의에 따라 Header에 Authorization인증방식을 집어넣을려면 Bearer를 공식적으로 사용하라고 해주고 있습니다.
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed){
response.setStatus(401);
}
LoginFilter에 unsuccessfulAuthentication을 만들어줍니다. 로그인 실패시 401에러를 내보냅니다.
그럼 회원가입을 한 이후에 localhost:8080에 localhost:8080/login으로 post요청을 날리면 위처럼 토큰을 발행해줍니다.
'Backend > 기능 구현' 카테고리의 다른 글
Spring Security JWT - 2. 프로젝트 생성 및 기본세팅 (0) | 2024.01.16 |
---|---|
Spring Security JWT - 1. 개념 및 동작 방식 (1) | 2024.01.13 |
Spring boot - 멀티 모듈 프로젝트를 만들어보자. (1) | 2024.01.02 |