Facts
✅ "Spring Security 사용하여, 인증 인가 구현한 부분"의 전체 구조 이해하기
✅ 모든 페이지에 접근할 경우 로그인해야 접속 가능하게 함 (로그인 페이지 제외하고 모든 페이지)
✅ 로그인 페이지에 접속할 때, 토큰이 있으면 home.html 가게 처리
✅ get user 처리 (create.html, mytil_page.html, my_page.html user 데이터 뿌려주기)
Findings
스프링 시큐리티를 이용한 로그인 처리
Spring Security를 사용하면, 인증 / 인가가 성공할 때에만, Controller에게 UserDetails(회원 정보)를 전달해준다.
로그인 처리 과정
1. 유저가 로그인을 시도하여, 서버에 http 요청이 들어온다.
2. AuthenticationFilter가 사용자가 보낸 정보를 intercept 한다. 이를 인증을 담당할 AuthenticationManager Interface에게 인증용 객체인 UsernamePasswordAuthenticationToken을 만든다.
3. UsernamePasswordAuthenticationToken을 AuthenticationManager에게 위임한다.
4. AuthenticationManager는 Authentication객체를 생성하고 실제로 인증할 AuthenticationProvider에게 Authentication 객체를 전달한다.
5-6. 그리고 UserDetailsService에 가서 이 Authentication 객체를 가지고 DB에 있는 유저임을 알게된다면, 조회된 회원객체(User)를 소유한 UserDetails라는 객체를 생성하게 된다.
7-8. UesrDetails를 가지고 Authentication Manager로 가서,
9-10. 인증(ex. 사용자가 입력한 ID, PW가 UserDetails 정보와 일치하는지?)과 인가(ex. 요청에 대한 권한을 UserDetails이 갖고 있는지?) 처리를 해준다.
그러므로, Spring Security를 사용하여 인증, 인가를 구현해주려면 다음과 같은 클래스를 구현해야 한다.
- UserDetailsService 인터페이스 → UserDetailsServiceImpl 클래스
- UserDetails 인터페이스 → UserDetailsImpl 클래스
로그인 구현 (Using JWT Token)
JWT Token을 발급하고 Client의 localStorage에 토큰을 저장하고 Ajax 요청마다 토큰을 들고오도록 구현해주었다.
- security > UserDetailsImpl
Spring Security에서 유저의 정보를 담는 인터페이스는 UserDetails 인터페이스다.
이 인터페이스를 구현하게 되면, Spring Security에서 구현한 클래스를 유저 정보로 인식하고 인증 작업을 한다.
유저 정보를 담아두는 클래스를 구현해주었다.
package com.cdp.tdp.security;
import com.cdp.tdp.domain.User;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.Collections;
public class UserDetailsImpl implements UserDetails {
private final User user;
private final boolean enabled = true;
public UserDetailsImpl(User user) {
this.user = user;
}
public User getUser() {
return user;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return enabled;
}
@Override
public boolean isAccountNonLocked() {
return enabled;
}
@Override
public boolean isCredentialsNonExpired() {
return enabled;
}
@Override
public boolean isEnabled() {
return enabled;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.emptyList();
}
}
- security > UserDetailsServiceImpl
유저 정보를 담을 객체를 만들어주었으니, DB에서 유저 정보를 직접 가져오는 인터페이스를 구현해주었다.
UserDetailService 인터페이스에는 DB에서 유저 정보를 불러오는 중요한 메소드가 존재한다.
바로 loadUserByUsername() 메소드다.
UserDetailsService 인터페이스를 구현하면
loadUserByUsername()메소드가 오버라이드가 될 것이며,
이 함수는 유저 DB에서 유저 정보를 가져와서 리턴해주는 작업을 한다.
package com.cdp.tdp.security;
import com.cdp.tdp.domain.User;
import com.cdp.tdp.repository.UserRepository;
import lombok.RequiredArgsConstructor;
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;
@RequiredArgsConstructor
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("로그인 오류"));
return new UserDetailsImpl(user);
}
}
- util > JwtTokenUtil.java
JWT Token을 생성하고 검증하는 기능을 수행하는 클래스를 만든다.
package com.cdp.tdp.util;
import io.jsonwebtoken.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import java.io.Serializable;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
@Slf4j
@Component
public class JwtTokenUtil implements Serializable {
private static final long serialVersionUID = -2550185165626007488L;
public static final long JWT_TOKEN_VALIDITY = 5 * 60 * 60;
@Value("${jwt.secret}")
private String secret;
//retrieve username from jwt token
public String getUsernameFromToken(String token) {
return getClaimFromToken(token, Claims::getSubject);
}
//retrieve expiration date from jwt token
public Date getExpirationDateFromToken(String token) {
return getClaimFromToken(token, Claims::getExpiration);
}
public <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) {
final Claims claims = getAllClaimsFromToken(token);
return claimsResolver.apply(claims);
}
//for retrieveing any information from token we will need the secret key
private Claims getAllClaimsFromToken(String token) {
return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
}
//check if the token has expired
private Boolean isTokenExpired(String token) {
final Date expiration = getExpirationDateFromToken(token);
return expiration.before(new Date());
}
//generate token for user
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
return doGenerateToken(claims, userDetails.getUsername());
}
//while creating the token -
//1. Define claims of the token, like Issuer, Expiration, Subject, and the ID
//2. Sign the JWT using the HS512 algorithm and secret key.
//3. According to JWS Compact Serialization(https://tools.ietf.org/html/draft-ietf-jose-json-web-signature-41#section-3.1)
// compaction of the JWT to a URL-safe string
private String doGenerateToken(Map<String, Object> claims, String subject) {
return Jwts.builder().setClaims(claims).setSubject(subject).setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + JWT_TOKEN_VALIDITY * 1000))
.signWith(SignatureAlgorithm.HS512, secret).compact();
}
//validate token
public Boolean validateToken(String token, UserDetails userDetails) {
final String username = getUsernameFromToken(token);
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
private Jws<Claims> getClaims(String jwt) {
try {
return Jwts.parser().setSigningKey(secret).parseClaimsJws(jwt);
} catch (SignatureException ex) {
log.error("Invalid JWT signature");
throw ex;
} catch (MalformedJwtException ex) {
log.error("Invalid JWT token");
throw ex;
} catch (ExpiredJwtException ex) {
log.error("Expired JWT token");
throw ex;
} catch (UnsupportedJwtException ex) {
log.error("Unsupported JWT token");
throw ex;
} catch (IllegalArgumentException ex) {
log.error("JWT claims string is empty.");
throw ex;
}
}
}
- dto > JwtResponse
client에게 token, username을 보내줄 dto를 만든다.
package com.cdp.tdp.dto;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Getter
public class JwtResponse{
private final String token;
private final String username;
}
- dto > UserDto
클라이언트로 부터 아이디와 비번을 받는 dto를 만든다.
package com.cdp.tdp.dto;
import lombok.Getter;
import lombok.Setter;
@Setter
@Getter
public class UserDto {
private String username;
private String password;
}
- security > JwtAuthenticationFilter.java
Spring Security 필터 부분이다. JWT 방식의 인증이 가능한 필터를 정의하여 추가해주었다.- client 요청에서 헤더에 Authorization을 포함하고 있고, Authorization이 Bearer로 시작하는지 체크해준다.
- 헤더가 위의 조건을 만족한다면,
- 토큰 유효여부를 검증한다.
- 유효하다면, 토큰에서 username을 파싱한다.
- username으로 회원 정보를 담고있는 UserDetails를 생성한다.
- userDetails를 통해, 인증용 객체인 UsernamePasswordAuthenticationToken을 만든다.
- SecurityContextHolder에 인증을 설정한다. 그 결과, 현재 사용자가 인증되도록 지정된다. - 헤더가 위의 조건을 만족하지 않는다면,
- 그냥 인증이 되지 않은 상태로 다음 필터로 넘어가도록 doFilter 처리한다.
- 헤더가 위의 조건을 만족한다면,
- client 요청에서 헤더에 Authorization을 포함하고 있고, Authorization이 Bearer로 시작하는지 체크해준다.
package com.cdp.tdp.controller;
import com.cdp.tdp.util.JwtTokenUtil;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.SignatureException;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.NotNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Collections;
@RequiredArgsConstructor
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final UserDetailsService userDetailsService;
private final JwtTokenUtil jwtTokenUtil;
String HEADER_STRING = "Authorization";
String TOKEN_PREFIX = "Bearer ";
@Override
protected void doFilterInternal(HttpServletRequest req, @NotNull HttpServletResponse res, @NotNull FilterChain chain) throws IOException, ServletException {
String header = req.getHeader(HEADER_STRING);
String username = null;
String authToken = null;
if (header != null && header.startsWith(TOKEN_PREFIX)) {
authToken = header.replace(TOKEN_PREFIX,"");
try {
// jwtToeknUtil 사용 (토큰에서 user 정보 가져오기)
username = jwtTokenUtil.getUsernameFromToken(authToken);
} catch (IllegalArgumentException e) {
logger.error("an error occured during getting username from token", e);
} catch (ExpiredJwtException e) {
logger.warn("the token is expired and not valid anymore", e);
} catch(SignatureException e){
logger.error("Authentication Failed. Username or Password not valid.");
}
} else {
logger.warn("couldn't find bearer string, will ignore the header");
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtTokenUtil.validateToken(authToken, userDetails)) {
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, Collections.singletonList(new SimpleGrantedAuthority("ROLE_ADMIN")));
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(req));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
chain.doFilter(req, res);
}
}
- controller > UserController
클라이언트로부터 User가 시도한 로그인 정보(아이디, 비번)을 받고, 아이디와 비번을 검증하고 이상이 없다면, userDetails 객체를 만들어준다. userDetails객체를 통해 토큰을 생성하여, 클라이언트에게 전달한다.
@RequiredArgsConstructor
@RestController
@Component
public class UserController {
private final UserService userService;
private final JwtTokenUtil jwtTokenUtil;
private final AuthenticationManager authenticationManager;
private final UserDetailsServiceImpl userDetailsService;
@PostMapping(value = "/login")
public ResponseEntity login(@RequestBody UserDto userDto) {
try {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(userDto.getUsername(), userDto.getPassword())
);
} catch (BadCredentialsException e) {
throw new BadCredentialsException("로그인 실패");
}
UserDetailsImpl userDetails = (UserDetailsImpl) userDetailsService.loadUserByUsername(userDto.getUsername());
final String token = jwtTokenUtil.generateToken(userDetails);
return ResponseEntity.ok(new JwtResponse(token, userDetails.getUsername()));
}
}
- security > WebSecurityConfig
모든 페이지의 요청마다, JWT token을 통해 인증과 인가를 처리하기 때문에,
stateless 정책을 사용한다. 여기서 stateless란, 세션에 사용자의 상태를 저장하지 않는다.
그리고 모든 요청에 토큰을 검증하는 필터를 추가하도록 설정해주었다.
package com.cdp.tdp.security;
import com.cdp.tdp.controller.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@RequiredArgsConstructor
@Configuration
@EnableWebSecurity // 스프링 Security 지원을 가능하게 함
@EnableGlobalMethodSecurity(securedEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
private final JwtAuthenticationFilter jwtRequestFilter;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable();
http.headers().frameOptions().disable();
http.authorizeRequests().antMatchers("/actuator/**").permitAll().anyRequest().permitAll();
// 인증을 session으로 관리 안함. jwt token으로 인증 → stateless 세션을 이용한다. (즉 사용자의 상태를 저장하지 않는다.)
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
// 모든 요청에 토큰을 검증하는 필터를 추가한다.
http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
}
// 비밀번호 암호화
@Bean
public BCryptPasswordEncoder encodePassword() {
return new BCryptPasswordEncoder();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
Feelings
security > WebSecurityConfig에서
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
처음에는 위의 정책을 사용하는 대신 아래를 사용해주었다.
http.sessionManagement().disable();
로그인 인증 방식을 세션 대신 토큰을 사용해주는 거니까, 세션을 사용하지 않으니 disable 하면 되겠지
하고 disable()를 때려넣었다. 그랬더니, 맨 처음 userA로 로그인 하고 난뒤, userB로 로그인을 해주어도,
userA가 유저로 계속 인식되었다. 🤯
🤔 세션에 대한 용어를 정확하게 인식하지 못하고, 공식문서를 확인하지 못해서
이 버그를 찾는데 오랜 시간이 걸렸다.
- 세션이라는 의미는 토큰과 같이 인증에 필요한 정보를 의미하는 것이 아니라, 웹 사이트의 여러 페이지에 걸쳐 사용되는 사용자 정보를 저장하는 방법을 말한다.
- SessionCreationPolicy.STATELESS설정을 해주어야
stateless 세션을 이용을 하고, 기존 사용자의 상태를 저장하지 않는다.
JWT Token을 사용하려면, 위의 설정을 해주어야 한다.
왜냐하면, JWT Token 매 요청마다 토큰을 가지고 사용자를 인증하는 방식을 선택이기 때문이다.
💡 용어를 정확히 인식하고, 공식문서를 참고해서 개발을 하자. ✏️
Future
- 회원가입 (중복 ID), 로그인 (아이디와 비번 일치하지 않는 경우, 유효한 토큰이 아닌 경우) 예외처리
- JWT 토큰 만료에 대한 예외처리
- Access Token + Refersh Token을 사용하여, JWT Token 안전하게 구현
출처
'Project > TIL, WIL' 카테고리의 다른 글
TIL(45) 21-12-03 : Spring에서 게시글 Pagination 처리하기 (0) | 2021.12.27 |
---|---|
TIL(44) 21-11-30 : S3와 CloudFront를 이용한 사진 업로드 구현하기 (0) | 2021.12.25 |
TIL(42) 11/22 - 11/27 : TDP 사이트 Flask 👉🏻 Spring (0) | 2021.12.22 |
TIL(41) 21-11-20 : 조회수, comment 기능 구현 (Flask) (0) | 2021.11.20 |
TIL(40) 21-11-17 : @Transactional / 스프링 Controller가 Client로 부터 파라미터를 받는 방법 / Lambda와 Stream (0) | 2021.11.17 |