2023. 12. 11. 20:11ㆍSpringboot
0. 개요
Spring boot를 이용한 OAuth로그인 구현을 위한 Spring boot Security에 대한 기초 지식
OAuth 로그인은 회원가입을 하지 않고 타사의 사이트의 접근 권한을 얻고 그 권한을 이용하여
개발할 수 있도록 도와주는 프레임워크이다. 구글, 카카오, 네이버등과 같은 사이트에서
로그인을 하면 직접 구현한 사이트에서도 로그인 인증을 받을 수 있도록 되는 구조이다.
1. Spring boot Security 방식
1) Client의 요청 (HttpRequest)
2) Spring boot의 Security가 해당 url을 찾아서 SecurityFilterChain에게 알맞게 매핑해 줌
// 예시 1) WebSecurityConfig.java
3) 해당 사항을 처리할 Security Filter가 해당 요청을 위해 응답하여 Client로 보내줌
// 예시 2) JwtUtil, JwtAuthorizationFilter, JwtAuthenticationFilter.java
예시 1) WebSecurityConfig.java
requestMatcher()에 해당하는 url에 대해서 접근이 이루어지면
authorizationFilter와 authenticationFilter를 지나치게 됨
package com.tmproject.Common.Security;
import com.tmproject.Common.Jwt.JwtAuthenticationFilter;
import com.tmproject.Common.Jwt.JwtAuthorizationFilter;
import com.tmproject.Common.Jwt.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
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;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity // Spring Security 지원을 가능하게 함
@RequiredArgsConstructor
public class WebSecurityConfig {
private final JwtUtil jwtUtil;
private final MemberDetailsServiceImpl memberDetailsService;
private final AuthenticationConfiguration authenticationConfiguration;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtUtil);
filter.setAuthenticationManager(authenticationManager(authenticationConfiguration));
return filter;
}
@Bean
public JwtAuthorizationFilter jwtAuthorizationFilter() {
return new JwtAuthorizationFilter(jwtUtil, memberDetailsService);
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// CSRF 설정
http.csrf((csrf) -> csrf.disable());
// 기본 설정인 Session 방식은 사용하지 않고 JWT 방식을 사용하기 위한 설정
http.sessionManagement((sessionManagement) ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
http.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests
// 정적 자원 허용 설정
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
// .requestMatchers(httpMethod, url).permitAll || .requestMatchers(url).permitAll()
// 인증 없이 해당 url접근 가능
.requestMatchers(HttpMethod.GET,"/api/member/boards/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/member/profile/**").permitAll()
.requestMatchers("/api/member/login","/api/member/loginPage").permitAll()
.requestMatchers("/api/member/signup").permitAll()
.requestMatchers("/api/member/kakao/callback").permitAll()
.requestMatchers("/api/member/naver/callback").permitAll()
.requestMatchers("/api/member/google/callback").permitAll()
// 위에 존재하는 해당 사항이 없다면 인증을 통해 접근 가능
.anyRequest().authenticated()
);
http.formLogin((formLogin) ->
formLogin
.loginPage("/api/member/loginPage").permitAll()
.defaultSuccessUrl("/homePage")
// loginPage 임시 지정
).logout(logout ->
logout
.logoutUrl("/api/member/logout")
.logoutSuccessUrl("/api/member/loginPage")
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID")
);
// 필터 관리
// jwtAuthorizationFilter -> jwtAuthenticationFilter순으로 필터 이동
http.addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class);
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
예시 2) JwtUtil.java
* createToken() : 해당 accessToken을 만들기 위한 메서드
* getJwtFromHeader() : header를 통해 Jwt token을 추출
* validationToken() : 토큰의 유효성 검증 // 토큰 만기되었는지? 유효한 형식인지?
* getUserInfoFromToken() : 토큰으로부터 userInfo를 가져옴
해당 토큰은 claim부분에 username을 가지고 있기에 가져올 수 있음.
package com.tmproject.Common.Jwt;
import com.tmproject.api.member.entity.MemberRoleEnum;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.security.Key;
import java.util.Base64;
import java.util.Date;
@Slf4j
@Component
public class JwtUtil {
public static final String AUTHORIZATION_HEADER = "Authorization";
public static final String AUTHORIZATION_KEY = "auth";
public static final String BEARER_PREFIX = "Bearer ";
private static final long TOKEN_TIME = 60 * 60 * 1000L;
// 1시간
private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
@Value("${jwt.secret_key}")
private String secretKey;
private Key key;
@PostConstruct
public void init(){
log.info("init start!");
byte[] bytes = Base64.getDecoder().decode(secretKey);
key = Keys.hmacShaKeyFor(bytes);
}
public String createToken(String username, MemberRoleEnum role){
log.info("토큰 생성");
Date date = new Date();
return BEARER_PREFIX + Jwts.builder()
.setSubject(username)
.claim(AUTHORIZATION_KEY,role)
.setExpiration(new Date(date.getTime()+TOKEN_TIME))
.setIssuedAt(date)
.signWith(key, signatureAlgorithm)
.compact();
}
public String getJwtFromHeader(HttpServletRequest req){
log.info("getJwtFromHeader()");
String bearerToken = req.getHeader(AUTHORIZATION_HEADER);
if(StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)){
return bearerToken.substring(7);
}
return null;
}
public boolean validateToken(String token) {
try {
log.info("validateToken");
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException | SignatureException e) {
log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
} catch (ExpiredJwtException e) {
log.error("Expired JWT token, 만료된 JWT token 입니다.");
} catch (UnsupportedJwtException e) {
log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
} catch (IllegalArgumentException e) {
log.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
}
return false;
}
public Claims getUserInfoFromToken(String token) {
log.info("getUserInfoFromToken");
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
}
// JWT 사용자 정보를 가져오기
}
예시 2) JwtAuthorizationFilter.java
* doFilterInternal() : JwtAuthorizationFilter로 들어올 시 바로 시행되며
accessToken을 받아 유효성 검증 후, 유저정보를 보유하게 되고
SecuritContextHolder에 존재하는 Authentication에 인증 객체를
setter를 사용하여 저장하는 로직입니다.
해당 사항은 WebSecurityConfig에서 requestMatcher().authenticated()에
해당하는 url에서 인가를 진행하며, requestMatcher().permitAll()은
필터를 거치긴 하나 tokenValue가 null이기에 authenticationFilter로 이동합니다.
// "/api/member/login" 같은 경우는 접근은 허용하지만 인증을 해야 하기에
// AuthenticationFilter.java에서 선언하여 확인하는 형태입니다.
* setAuthentication() : 해당 인증 객체를 setter를 통해 저장합니다.
* createAuthentication() : 인증 객체를 저장하는 메서드입니다.
authorizationFilter와 authenticationFilter를 지나치게 됨
package com.tmproject.Common.Jwt;
import com.tmproject.Common.Security.MemberDetailsServiceImpl;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Slf4j(topic = "JWT 검증 및 인가")
public class JwtAuthorizationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final MemberDetailsServiceImpl memberDetailsService;
public JwtAuthorizationFilter(JwtUtil jwtUtil, MemberDetailsServiceImpl memberDetailsService) {
this.jwtUtil = jwtUtil;
this.memberDetailsService = memberDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain filterChain) throws ServletException, IOException {
log.info("doFilterInternal()");
String tokenValue = jwtUtil.getJwtFromHeader(req);
log.info("tokenValue : "+tokenValue);
// 여기 null
if (StringUtils.hasText(tokenValue)) {
if (!jwtUtil.validateToken(tokenValue)) {
log.error("Token Error");
return;
}
Claims info = jwtUtil.getUserInfoFromToken(tokenValue);
try {
setAuthentication(info.getSubject());
} catch (Exception e) {
log.error(e.getMessage());
return;
}
}
filterChain.doFilter(req, res);
}
// 인증 처리
public void setAuthentication(String username) {
log.info("setAuthentication()");
// 새로운 시큐리티 context를 만듦
SecurityContext context = SecurityContextHolder.createEmptyContext();
// SecurityContextHolder안에 존재하는 Authentication에 createAuthentication(username)을 하여
// 해당 인증 객체에 username에 넣어줍니다.
Authentication authentication = createAuthentication(username);
// contextHolder에 인증 객체를 넣어줌
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
}
// 인증 객체 생성
private Authentication createAuthentication(String username) {
log.info("createAuthentication()");
UserDetails userDetails = memberDetailsService.loadUserByUsername(username);
return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
}
}
예시 2) JwtAuthentcationFilter .java
* JwtAuthenticationFilter의 생성자를 통해 setFilterProcessUrl(url)로 흘러 들어오는
url은 AuthenticatioFilter에서 검증을 한 후 보내준다는 내용을 포함하고 있습니다.
* attemptAuthenticationFilter() : LoginRequestDto로 들어오는 데이터를 JSON형태로
받아서 UsernamePasswordAuthenticationToken()이라는 곳에 이름, 패스워드, 역할
순서로 저장합니다. 만약 입출력 Exception이 터지면 시행하지 않습니다.
* successfulAuthentication() : 인증이 성공하면 accessToken을 만들어줍니다.
accessToken을 client에게 setHeader() 함수를 통하여 header부분에 전달해 줍니다.
// 밑의 그림 3번, 4번 과정입니다.
* unsuccessfulAuthentication() : 인증 실패 및 statusCode를 401로 반환합니다.
* 이후 커스텀 필터를 만들지 않았다면 해당 url을 dispatcherServlet으로 이동시킵니다.
package com.tmproject.Common.Jwt;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.tmproject.Common.Security.MemberDetailsImpl;
import com.tmproject.api.member.dto.LoginRequestDto;
import com.tmproject.api.member.entity.MemberRoleEnum;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import java.io.IOException;
@Slf4j(topic = "로그인 및 JWT 생성")
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final JwtUtil jwtUtil;
public JwtAuthenticationFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
setFilterProcessesUrl("/api/member/login");
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
log.info("attemptAuthentication()");
log.info("request : "+request);
try {
LoginRequestDto requestDto = new ObjectMapper().readValue(request.getInputStream(), LoginRequestDto.class);
return getAuthenticationManager().authenticate(
new UsernamePasswordAuthenticationToken(
requestDto.getUsername(),
requestDto.getPassword(),
null
)
);
} catch (IOException e) {
log.error(e.getMessage());
throw new RuntimeException(e.getMessage());
}
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) {
log.info("successfulAuthentication()");
String username = ((MemberDetailsImpl) authResult.getPrincipal()).getUsername();
MemberRoleEnum role = ((MemberDetailsImpl) authResult.getPrincipal()).getMember().getRole();
String token = jwtUtil.createToken(username, role);
log.info(token);
// 토큰 요청할 때 잘못 보내는거 같음,
response.setHeader(JwtUtil.AUTHORIZATION_HEADER, token);
// response.setHeader(); 없을 때 넣어주는데, 중복된 토큰이 있으면 업데이트 해준다.
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) {
response.setStatus(401);
}
}
해당 jwt token 전달 과정 :
'Springboot' 카테고리의 다른 글
Spring boot 간단한 AWS S3 MultipartFile 업로드 (0) | 2023.12.13 |
---|---|
Spring boot 순환 참조 문제 (0) | 2023.12.12 |
Spring boot OAuth 로그인(1) (1) | 2023.12.07 |
Spring boot 환경 변수 설정 (1) | 2023.12.05 |
Spring boot Test code(1) (0) | 2023.12.04 |