jwt (Json Web Token)을 사용해 보장.
아래 글을 읽고 오는 걸 기대하지만 머~ 안 읽고 싶다면 그것도 괘안탕!
2024.03.11 - [스프링] - spring boot 3 security 그냥 한번 해보고 한번 더 해보면 잘 될꺼얼!
랑 다른 점은 더이상 서버사이드에 인증 정보를 세션으로
저장하지 않는다가 핵심이당. 인증된 클라이언트에게 사랑의 증표(토큰)를 주면
클라이언트는 요청할 때마다 그 증표를 같이 제시해야 한다.
증표에 사탕은 3개까지라 되어 있는데, 5개를 요청한다면 그건 퇴짜당.
클라이언트는 증표를 잘 관리해야 한다. 안 그럼 그 증표를 주운 사람이
사탕 3개를 먼저 먹어버릴 수 있당. OK?
세션을 이용한 시큐리티도 마찬가지지만, 흐름에 집중해서 몇번 읽으면
별거 아닌데, 처음부터 너무 코드에 집중하면 오히려 잘 안보인당.
편한 맘으로 쭉쭉 읽고, 해보고 또 읽어보장.
운전도 자주하면 엔진이 건강한지/아픈지 느끼미 온당.
스프링 시큐리티에서 제공하는 필터는 현실에 비유하면 검문소에 비유할 수 있다.
기본적으로 UsernamePasswordAuthenticationFilter 라는 ID와 암호로 인증을
해주는 검문소가 제공되는데 그 앞에 토큰으로 인증을 미리 처리하는
필터(검문소)를 추가할 거당. 여기서 인증되면 다음 검문소는 그냥 통과당.
이렇게 하면 문제가 발생한다. 처음 접속하는 사람은 토큰이 없는뎅
이 검문소를 통과할 수 없게 된당. 이 사람은 UsernamePasswordAuthenticationFilter
검문소에서 인증을 받아서, 토큰을 받아야 다음 요청에 사용가능하당.
곧 UsernamePasswordAuthenticationFilter로 직접 가는 사람은 토큰 검문소를
그냥 통과시키세용 하는 설정이 필요하당. 그냥 그렇당.
대략 정리하면 다음과 같다. (지금은 너무 꼬치 꼬치 따질때가 아니다)
1. 클라이언트 ID/암호로 시큐리티 로그인 성공
2. 서버 jwt accessToken(단기사용) / refreshToken(장기사용) 제공
3. 클라이언트는 요청시 마다 제공받은 토큰으로 본인 인증
4. accessToken이 만료되었다면 refreshToken이용 accessToken 재 발급
5. refreshToken도 만료 되었다면 다시 로그인
그럼 일단 발을 내디뎌 보장. 결국엔 별게 없다.
기존 시큐리티 글에 여러가지를 참고해서 정리했당. (난 Deprecated 메세지가 아주 밉당!)
STS4로 아래처럼 boot 프로젝트를 맹글어보장.
Dependency는 아래처럼 선택하장
전에는 jwt 토큰 제어용 애가 몇개 있었는데, 아래 애가 오징어게임에서 승자인가 보다
jjwt 라이브러리 추가하장
<!-- jjwt -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
</dependency>
최종 디렉토리와 파일 리스트는 아래와 같당. (zip 파일로 줄깡?)
로깅, DB연결(난 오라클), mybatis 등을 설정하장.
application.properties
spring.application.name=jwtsec
# logging 설정
logging.level.com.e7e.jwtsec=debug
logging.level.org.springframework.security=debug
# database 설정 oracle 연결
spring.datasource.url=jdbc:oracle:thin:@localhost:1521/xe
spring.datasource.username=e7e
spring.datasource.password=java
# mybatis 설정
mybatis.type-aliases-package=com.e7e.jwtsec.vo
mybatis.configuration.map-underscore-to-camel-case=true
mybatis.configuration.jdbc-type-for-null=varchar
mybatis.mapper-locations=classpath:mybatis/mapper/*_SQL.xml
위 설정대로 com.e7e.jwtsec 패키지 아래에 vo 패키지 만들기
src/main/resources 아래에 mybatis 폴더 와 그 아래에 mapper 폴더 만들기
DB에 sql developer 이용 쏘우 심플 멤버/권한 테이블 맹글기
-- 시규리티용 억지 member 테이블
create table tb_members(
mem_id varchar2(10) not null primary key,
mem_name varchar2(50) not null,
mem_pw varchar2(60) not null,
mem_regdate date default sysdate
);
-- 시큐리티용 억지 권한 테이블
create table tb_auths(
mem_id VARCHAR2(10) not null,
auth_name VARCHAR2(20) not null,
CONSTRAINT fk_memid FOREIGN key(mem_id) REFERENCES tb_members(mem_id)
);
create UNIQUE index index_mem_id_auth_name on tb_auths(mem_id,auth_name);
MemberVO.java (AuthVO는 이번에는 안 만들꺼얌)
package com.e7e.jwtsec.vo;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Getter
@Setter
@ToString
public class MemberVO {
private String memId;
private String memName;
private String memPw;
private String memRegdate;
private List<String> authList;
public Map<String, Object> getClaims(){
// claim담을 Map
Map<String, Object> mapClaim = new HashMap<>();
mapClaim.put("memId",memId);
mapClaim.put("memName",memName);
mapClaim.put("memRegdate",memRegdate);
mapClaim.put("authList",authList);
return mapClaim;
}
}
getClaims 메소드는 나중에 토큰에 담고 싶은 정보만 Map 형태로 리턴하게 했당.
mapper 패키지 만들고, 그 아래에 맵퍼 I/F 만들기
그냥 Member / Auth insert 와 Member select용 이당.
괘니 VO만 쓰기 그래서 @Param도 사용해 보았당. (덕분에 AuthVO 안 맹글었당~~)
MemberMapper.java
package com.e7e.jwtsec.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import com.e7e.jwtsec.vo.MemberVO;
@Mapper
public interface MemberMapper {
public int insertMember(MemberVO memberVO);
public int insertAuth(@Param("memId") String memId, @Param("authName") String authName);
public MemberVO read(String memId);
}
위 Mapper I/F 와 연결될 sql 문이당.
MemberMapper_SQL.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.e7e.jwtsec.mapper.MemberMapper">
<resultMap type="MemberVO" id="memberMap">
<id column="mem_id" property="memId" />
<result column="mem_id" property="memId" />
<result column="mem_pw" property="memPw" />
<result column="mem_name" property="memName" />
<result column="mem_regdate" property="memRegdate" />
<collection property="authList" column="mem_id"
javaType="List" ofType="String" select="findRolesById" />
</resultMap>
<insert id="insertMember" parameterType="MemberVO">
insert into tb_members(mem_id,mem_name,mem_pw)
values(#{memId},#{memName},#{memPw})
</insert>
<!-- 괘니 @Param 이용 -->
<insert id="insertAuth" >
insert into tb_auths(mem_id, auth_name) values(#{memId},#{authName})
</insert>
<select id="findRolesById" resultType="String" parameterType="String">
select auth_name from tb_auths where mem_id=#{memId}
</select>
<select id="read" parameterType="string" resultMap="memberMap">
select
m.mem_id, mem_pw, mem_name, mem_regdate
from tb_members m
where m.mem_id = #{memId}
</select>
</mapper>
위에서 만든 Mapper를 테스트도 하고, 사용자와 권한을 DB에 넣도록 하장.
@Disabled를 1개씩 주석처리하고, Junit을 실행하면 된당. 오켕?
MemberMapperTest.java
package com.e7e.jwtsec.mapper;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.List;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import com.e7e.jwtsec.vo.MemberVO;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@SpringBootTest
public class MemberMapperTest {
// 암호화및 암호체크용
private static PasswordEncoder bcryptPasswordEncoder = new BCryptPasswordEncoder();
@Autowired
private MemberMapper memberMapper;
@Test
@DisplayName("member(사용자) 등록 test")
// @Disabled
public void insMemberTest() {
// 샘플용 Id & Name
List<String> memberIds = List.of("e7e","roze","karina","nobi");
List<String> memberNames = List.of("272","로제","카리나","노비");
MemberVO memberVO = null;
int totalRow = 0;
for(int index=0; index < memberNames.size(); index++) {
memberVO = new MemberVO();
memberVO.setMemId(memberIds.get(index));
memberVO.setMemName(memberNames.get(index));
// 암호 인코딩 필요!, 암호는 Id와 그냥 같겡
memberVO.setMemPw(bcryptPasswordEncoder.encode(memberIds.get(index)));
totalRow +=memberMapper.insertMember(memberVO);
}
assertEquals(memberNames.size(), totalRow);
}
@Test
@DisplayName("auth(권한) 등록 test")
@Disabled
public void insAuthTest() {
MemberVO memberVO = new MemberVO();
List<String> memberIds = List.of("e7e","roze","karina","nobi","ksj");
int totalRow = 0;
for (String memId : memberIds) {
switch (memId) {
case "e7e":
totalRow += memberMapper.insertAuth(memId,"ROLE_CEO");
totalRow += memberMapper.insertAuth(memId,"ROLE_MANAGER");
break;
case "roze" :
case "karina":
totalRow += memberMapper.insertAuth(memId,"ROLE_MANAGER");
break;
case "nobi":
totalRow += memberMapper.insertAuth(memId,"ROLE_ALBA");
break;
default: // for ksj, member, auth 양쪽에 1번에 등록
memberVO.setMemId(memId);
memberVO.setMemName("friend_"+memId);
// 암호 인코딩 필요!, 암호는 Id와 그냥 같겡
memberVO.setMemPw(bcryptPasswordEncoder.encode(memId));
totalRow +=memberMapper.insertMember(memberVO);
totalRow += memberMapper.insertAuth(memId,"ROLE_USER");
}
}
assertEquals(7, totalRow);
}
@Test
@DisplayName("read member(사용자검색) test")
@Disabled
public void readMember() {
MemberVO memberVO = memberMapper.read("e7e");
//암호 확인!
log.debug("암호일치: {}",bcryptPasswordEncoder.matches("e7e", memberVO.getMemPw()));
log.debug("ck: {}",memberVO);
memberVO.getAuthList().forEach(authVO -> log.debug("{}",authVO));
assertEquals(2, memberVO.getAuthList().size());
}
}
요따구 데이터가 테이블에 담기게 될꺼당.
시큐리티 전체 설정 부터 보장. (처음엔 그냥 복사/붙여넣기가 좋당)
SecurityConfig.java
package com.e7e.jwtsec.config;
import java.util.Arrays;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
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.core.userdetails.UserDetailsService;
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.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import com.e7e.jwtsec.security.CustomAccessDeniedHandler;
import com.e7e.jwtsec.security.CustomLoginFailureHandler;
import com.e7e.jwtsec.security.CustomLoginSuccessHandler;
import com.e7e.jwtsec.security.CustomUserDetailsService;
import com.e7e.jwtsec.security.JwtAuthenticationFilter;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Configuration
@EnableWebSecurity // auth.requestMatchers("/e7e").hasRole("CEO")
@EnableMethodSecurity // @PreAuthorize , @PostAuthorize
public class SecurityConfig {
@Bean // 핵심 설정
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
log.debug("시큐리티 설정");
http.csrf(csrf->csrf.disable())
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(sess -> sess.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth.requestMatchers("/e7e").hasRole("CEO")
.requestMatchers("/api/login").permitAll()
.requestMatchers("/api/refresh").permitAll()
.anyRequest().authenticated())
.formLogin(form-> form.loginPage("/api/login")
.successHandler(customLoginSuccessHandler())
.failureHandler(customLoginFailureHandler()))
.exceptionHandling(ex -> ex.accessDeniedHandler(customAccessDeniedHandler()));
http.addFilterBefore(new JwtAuthenticationFilter(),UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean // 인증매니저 스프링 문서 참조, global 설정 복사해옴 formLogin이 있으면 없어도 됨!
AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration)
throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean // 인증제공자 인증처리 formLogin이 있으면 없어도 됨!
AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
authenticationProvider.setUserDetailsService(customUserDetailsService());
authenticationProvider.setPasswordEncoder(passwordEncoder());
return authenticationProvider;
}
@Bean // 사용자 정의 UserDetailsService
UserDetailsService customUserDetailsService() {
return new CustomUserDetailsService();
}
@Bean // 인증성공 핸들러
AuthenticationSuccessHandler customLoginSuccessHandler() {
return new CustomLoginSuccessHandler();
}
@Bean // 인증실패 핸들러, 안 맹글어도 크게 안 불편
AuthenticationFailureHandler customLoginFailureHandler() {
return new CustomLoginFailureHandler();
}
@Bean // 접근거부 처리 핸들러, 필요
AccessDeniedHandler customAccessDeniedHandler() {
return new CustomAccessDeniedHandler();
}
@Bean // 암호를 암호화
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean // Cors 전역설정, 컨트롤러 @CrossOrigin 사용해도 됨
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(Arrays.asList("*"));
configuration.setAllowedMethods(Arrays.asList("HEAD", "GET", "POST", "PUT", "DELETE"));
configuration.setAllowedHeaders(Arrays.asList("Authorization", "Cache-Control", "Content-Type"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
뽀인또는 아래와 같당. 나머지는 일단 무시당.
"/api/login" url로 username과 password를 보내면 시큐리티 인증절차를 진행한당.
csrf를 disable 시키고, session정책을 STATELESS로 설정
cors 전역설정 부분 -> 이건 나중에 @CrossOrigin으로 메소드별 대체해도 상관없당.
formLogin(form-> form.loginPage("/api/login")
시큐리티가 제공해주는 User 클래스를 확장하면, 필요한 정보를 더 담을 수 있당. 그렇당.
CustomUser.java
package com.e7e.jwtsec.security;
import java.util.Collection;
import java.util.stream.Collectors;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import com.e7e.jwtsec.vo.MemberVO;
import lombok.Getter;
@SuppressWarnings("serial")
@Getter
public class CustomUser extends User {
private MemberVO member;
public CustomUser(String username, String password, Collection<? extends GrantedAuthority> authorities) {
super(username, password, authorities);
}
public CustomUser(MemberVO memberVO) {
super(memberVO.getMemId(),memberVO.getMemPw(),
memberVO.getAuthList().stream()
.map(authName -> new SimpleGrantedAuthority(authName)).collect(Collectors.toList()));
this.member = memberVO;
}
}
멤버(사용자)를 DB에서 찾는 방법을 시큐리티에 제공해야 한당.
찾으면 CustomUser를 생성하여 돌려준당.
CustomUserDetailsService.java
package com.e7e.jwtsec.security;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import com.e7e.jwtsec.mapper.MemberMapper;
import com.e7e.jwtsec.vo.MemberVO;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class CustomUserDetailsService implements UserDetailsService{
@Autowired
private MemberMapper memberMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
log.debug("잘 오낭? 확인 {}",username);
MemberVO memberVO = memberMapper.read(username);
if(memberVO != null) {
return new CustomUser(memberVO);
}else {
throw new UsernameNotFoundException(username);
}
}
}
토큰 예외 처리를 위한 (예외메세지) MyException을 그냥 괘니 한개 만들장.
MyJWTException.java
package com.e7e.jwtsec.util;
@SuppressWarnings("serial")
public class MyJWTException extends RuntimeException {
public MyJWTException(String msg) {
super(msg);
}
}
아 요게 조금 낯선거당. 괜찮당. (검색해서 다양한 스타일을 보면 더 좋당)
큰 흐름만 보장. 토큰 만들어주는 메소드와 토큰 검증 메소드 2개당. 그렇당.
Bean으로 처리해도 되지만, 자주 써야 하니깡 static으로 쓰는 게
autowired 안해도 되고 해서 편하고 효율적일꺼이당.
JWTUtil.java
package com.e7e.jwtsec.util;
import java.nio.charset.StandardCharsets;
import java.time.ZonedDateTime;
import java.util.Date;
import java.util.Map;
import javax.crypto.SecretKey;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.JwtParser;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
@Slf4j // static?, Bean으로 하면 autowird 불편할수도
public class JWTUtil {
private static String jwtSecret = "merong-e7e-jwt-merong-e7e-jwt-merong-e7e-jwt";
private static SecretKey secretKey =
Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
// 토큰 생성, 담을 정보를 Map형태로 받음
public static String genToken(Map<String, Object> valMap, int min) {
String token = Jwts.builder()
.header().add(Map.of("typ","JWT"))
.and()
.claims(valMap)
.issuedAt(Date.from(ZonedDateTime.now().toInstant()))
.expiration(Date.from(ZonedDateTime.now().plusMinutes(min).toInstant()))
.signWith(secretKey, Jwts.SIG.HS256)
.compact();
return token;
}
// 토큰 검증
public static Jws<Claims> validate(String token) throws Exception{
JwtParser parser = Jwts.parser().verifyWith(secretKey).build();
Jws<Claims> jws = null;
try {
jws = parser.parseSignedClaims(token);
jws.getPayload().forEach((key,value) -> log.info("{} {}",key,value));
}catch (ExpiredJwtException expiredJwtException) {
throw new MyJWTException("Expired"); // 만료 확인 위해 필요행!
}catch (Exception e) {
throw new MyJWTException("Error");
}
return jws;
}
}
로그인에 성공하면 위 JWTUTIL 클래스를 이용하여 토큰(access/refresh Token)을 만들고
해당 토큰을 Map 형태(추가정보) 안에 담아서 클라이언트에게 돌려준당.
CustomLoginSuccessHandler.java
package com.e7e.jwtsec.security;
import java.io.IOException;
import java.util.Map;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import com.e7e.jwtsec.util.JWTUtil;
import com.e7e.jwtsec.vo.MemberVO;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class CustomLoginSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
log.debug("인증에 성공했나 봐요?");
CustomUser customUser = (CustomUser) authentication.getPrincipal();
MemberVO member = customUser.getMember();
log.debug("체에킁 member: {}", member); //
Map<String, Object> claims = member.getClaims();
claims.put("accessToken", JWTUtil.genToken(claims, 60));
claims.put("refreshToken", JWTUtil.genToken(claims, 24 * 60));
ObjectMapper objMapper = new ObjectMapper();
String jsonStr = objMapper.writeValueAsString(claims);
log.debug("JSON 형태 {}", jsonStr);
response.setCharacterEncoding("UTF-8"); // 한글 안 깨지갱
response.getWriter().println(jsonStr);
response.getWriter().close();
}
}
로그인에 실패하면 ( id/암호가 일치하지 않으면) 에러 전송, 단순!
CustomLoginFailureHandler.java
package com.e7e.jwtsec.security;
import java.io.IOException;
import java.util.Map;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class CustomLoginFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
log.debug("인증 실패한 이유는 {}",exception.getMessage());
ObjectMapper objMapper = new ObjectMapper();
String jsonStr = objMapper.writeValueAsString(Map.of("Error","LOGIN_ERROR"));
response.setCharacterEncoding("UTF-8");
response.getWriter().println(jsonStr);
}
}
접근 권한이 없는 곳에 접근하려 할 때 처리 , 역시 에러
CustomAccessDeniedHandler.java
package com.e7e.jwtsec.security;
import java.io.IOException;
import java.util.Map;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class CustomAccessDeniedHandler implements AccessDeniedHandler{
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException, ServletException {
log.debug("권한에 문제강?: {}",accessDeniedException.getMessage());
ObjectMapper objMapper = new ObjectMapper();
String jsonStr = objMapper.writeValueAsString(Map.of("Error","Authority_ERROR"));
response.setCharacterEncoding("UTF-8");
response.getWriter().println(jsonStr);
}
}
요기서 한번 확인하장! 토큰 필터를 만들어서 등록하지 않았으므로
SecurityConfig.java 파일에서 아래처럼 http.addFilter 주석 처리 한 다음에 실행하장.
//http.addFilterBefore(new JwtAuthenticationFilter(),UsernamePasswordAuthenticationFilter.class);
난 Boomerang으로 formlogin url인 /api/login에 post방식으로 username과 password에
테이블에 이미 등록된 아이디 e7e 암호 e7e로 전송 결과 아래와 같은 결과가 짜안! (잘된당)
생각나는게 있다면 개인적으로 더 테스트를 해보장.
예를 들면 Token 유효 시간을 아주 짧게 해서 테스트....
이제 중요한 뽀인뜨당. 토큰 필터를 만들장.
JwtAuthenticationFilter.java
package com.e7e.jwtsec.security;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import com.e7e.jwtsec.util.JWTUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override // 필터
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
log.debug("여긴 JWT 인증 필터지용~");
String token = getTokenFromRequest(request);
try {
Jws<Claims> claims =JWTUtil.validate(token);
log.debug("체킁 claims {}",claims.toString());
String username = claims.getPayload().get("memId", String.class);
log.debug("체킁 username{}",username);
log.debug("체킁 authList{}",claims.getPayload().get("authList", List.class));
var authList = claims.getPayload().get("authList", List.class);
// 스프링이 원하는 GrantedAuthority로 바꾸깅
List<SimpleGrantedAuthority> roleList = new ArrayList<>();
for (Object object : authList) {
roleList.add(new SimpleGrantedAuthority((String)object));
}
// 강제 인증 (인증 되었음)
Authentication auth = new UsernamePasswordAuthenticationToken(username,null,roleList);
SecurityContextHolder.getContext().setAuthentication(auth);
// 통과
filterChain.doFilter(request, response);
}catch(Exception e) {
log.debug("토큰에 문제 있어용 {}",e.getMessage());
ObjectMapper objMapper = new ObjectMapper();
String jsonStr = objMapper.writeValueAsString(Map.of("Error","ERROR_TOKEN"));
response.setCharacterEncoding("UTF-8");
response.getWriter().println(jsonStr);
}
}
@Override // 필터 할지 안할지, true면 필터링 안하공, false면 하는 거얌
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
if(request.getMethod().equals("OPIONS")) {
return true;
}
String path = request.getRequestURI();
log.debug("통과하는 URI는 {}",path);
// login 하고 토큰 재발급 하는데는 필터링 하지 않을 거얌
if(path.startsWith("/api/login") || path.startsWith("/api/refresh") ) {
return true;
}
// 위에 꺼 빼고는 token 체크 할꺼얌
return false;
}
// JWTUtil에 넣으려면 public으로 해야하닝 보안상 쪼메 문제 소지
private String getTokenFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if(StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7,bearerToken.length());
}
return "토큰없엉?";
}
}
아까 처음 로그인 할때는 토큰이 없고 id 와 암호로 인증 되어야 한다고 했당.
그렇당. shoudNotFilter 메소드가 바로 그걸 처리하는 곳이다.
"/api/login"과 "/api/refresh"로 가는 요청은 토큰 검증에서 제외된당.
return true면 제외, return false이면 필터에서 검증 당한당.
getTokenFromRequest 메소드는 보면 그냥 바로 보이는데
Request 요청에서 토큰만 빼내는 메소드이당.
doFilterInternal에서 중요한 건 토큰을 검증하고, 문제 없으면 강제로 인증 처리하는데
강제 인증 처리할 때, 토큰에서 빼낸 권한정보를 같이 처리해서
Role 베이스 권한 처리가 가능하도록 한다는 거이당.
위 토큰 필터를 사용하기 위해서, 처음 확인 때 주석 처리했던 아래 부분을 원래대로 돌린당.
http.addFilterBefore(new JwtAuthenticationFilter(),UsernamePasswordAuthenticationFilter.class);
여기서 갑자기 막 테스트하고 싶은 맘이 들지만 잠시만 참자
Refresh 토큰 기능을 테스트 하기 위한 컨트롤러를 아래처럼 맹글장.
accessToken이 만료가 안되었다면 재 발행없이 돌려 보내고,
만료되었다면 refreshToken을 검증해서 재 발행하고, refreshToken도 남은 시간이
2시간 이하면 재 발행하도록 되어있당. (시간 체크 메소드가 있당.)
RefreshController.java
package com.e7e.jwtsec.controller;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.e7e.jwtsec.util.JWTUtil;
import com.e7e.jwtsec.util.MyJWTException;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@RestController
@RequestMapping("/")
public class RefreshController {
// refresh 토큰을 이용 access 토큰 재발행 요청
@PostMapping("/api/refresh")
public Map<String, Object> refresh(
@RequestHeader("Authorization") String authHeader,
String refreshToken) throws Exception{
log.debug("체킁 {}",refreshToken);
if(refreshToken == null) {
throw new Exception("RefreshToken null");
}
if(authHeader == null || authHeader.length() < "Bearer ".length()) {
throw new Exception("토큰값을 안 보냈넹");
}
String accToken = authHeader.substring(7);
if(!checkExpired(accToken)) {
return Map.of("accessToken",accToken, "refreshToken",refreshToken);
}
Jws<Claims> claims = JWTUtil.validate(refreshToken);
log.debug("체킁 refresh claims {}",claims);
Map<String,Object> mapClaims = new HashMap<>();
claims.getPayload().forEach((key,value)-> {
mapClaims.put(key, value);
});
log.debug("체킁 claims map변환 {}",mapClaims);
String newToken = JWTUtil.genToken(mapClaims, 60);
String newRefToken = refreshToken;
if(ckLeftTime((Long)mapClaims.get("exp"))) {
newRefToken = JWTUtil.genToken(mapClaims, 60*24);
}
return Map.of("accessToken",newToken,"refreshToken",newRefToken);
}
/////////////////////// 내부 필요 메소드 ////////////////////////////////
// 남은 시간 계산
private boolean ckLeftTime(Long exp) {
Date expiredDate = new Date((long)exp*1000);
long millsecGap = expiredDate.getTime() - System.currentTimeMillis();
int leftHour = (int)(millsecGap / (1000*60*60)); // 시간
return leftHour < 2;
}
// 만료 되었는가?
private boolean checkExpired(String token) throws Exception {
// 산수로 계산해도 되긴 하겠징...
try {
JWTUtil.validate(token);
}catch (MyJWTException mje) {
log.debug("체킁: {}",mje.getMessage());
if(mje.getMessage().equals("Expired")) return true;
}
return false;
}
}
"/api/refresh" 컨트롤러를 테스트 해보자. 아까 받았던 Token값을 저장해 두었어야 한다.
없다면 다시 "/api/login"으로 요청해서 받도록 하장.
accessToken이 이미 만료된 상황이라면 더 좋당.
BODY에 refreshToken으로 적고, refreshToken 값을 복사 붙여넣는다.
HEADERS에 Authorization 키값에 Value는 Bearer 토큰값으로 넣고 send 버튼을 사알짝 누른당.
accessToken과 refreshToken값이 잘 발행되었음을 알 수 있당.
자 이제 막바지당. Role 권한이 잘 되는지를 확인해 보장.
E7EController.java
package com.e7e.jwtsec.controller;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@RestController
@RequestMapping("/")
public class E7EController {
// authenticated 사용자만 접근 가능
@GetMapping("/hello")
public String insa(Authentication auth) {
return "Hello " + auth.getName();
}
// Method Security 테스트용
@PreAuthorize("hasAnyRole('ROLE_CEO','ROLE_MANAGER')")
public String karina() {
return "Hello Karina";
}
// WebSecurity로 ROLE_CEO만 접근가능
// auth.requestMatchers("/e7e").hasRole("CEO") SecurityConfig에 설정함
@GetMapping("/e7e")
public String e7e() {
return "Hello E7E CEO";
}
}
토큰 없이 접속 "/hello" 호출 토큰 에러 발생
username/password에 karina/karina 를 입력 후 "/api/login" 에 요청, 어딘가에 토큰 저장(메모장?)
받은 토큰을 Header에 담고서 "/hello" 요청 -> 썽공
권한없는 "/e7e" 에 요청 -> 권한 에러
아마도 STS console엔 CustomAccessDeniedHandler : 권한에 문제강?: Access Denied 가 보일거당
어찌 어찌 여기까지 읽었다면 당신은 이미 대단하당.
처음이라면 분명 머릿속에 핵심만 정리되지는 않았을 거고,
검색을 통해 확인해야 할 것들이 뇌를 노크 할 것이다.
오늘은 피곤하다면 내일 꼭 다시 읽고 확인하장.~~ 쉴때는 쉬어야 한당.
소탐대실하지 말장!!
아~~ 다음은 이걸 사용하는 React 글을 써야 한당. (근데 갑자기 의문이 든다. 왜?)
그림 그리는 툴을 잘 쓸 줄 알았다면
그림을 그렸을텐데
..... .... .....
생각해 보니, 툴 문제가 아니고
원래 그림 자체에 재주가 없당
.... .... ......
스마트 하다는 건 머얼까?
공간에선 먼 곳.... 시간에선 먼 앞을 본다는 걸까
..... .... ....
하나를 보면 열을 안다? 스마트?
토큰은 한개 만들어 보면, 무한대로 만들 수 있다.
당신은 이미 무한 스마트?
.... ..... ..... .....
https://www.youtube.com/watch?v=KNexS61fjus
Spring boot WebSocket(스프링 부트 웹소켓 사용) (8) | 2024.11.11 |
---|---|
sts4 spring boot 세번쨍 (0) | 2023.07.10 |
sts4 spring boot 두번쨍 (0) | 2023.07.04 |
sts4 spring boot 첫번쨍 (0) | 2023.07.03 |