안녕하세요
앵과장입니다.
이전 블로그에서는 Spring Secruit 를 구현하기위해서 필요한 내용들에 대해서 개념적인 부분과 필요한 부분들에 대해서 정의해보았습니다.
농담처럼 왜필요한지 동기부여를 적어봤는데!!
Spring Secruit 를 쓰는 주된이유는 아무래도 여러 filter 기능 그리고 라이프 사이클 때문입니다.
모든것들을 직접 구현 할수는 있지만 URL 호출 전 호출 이 후 로그인전 로그인 이후 오류처리 등등등 여러부분에 End Point를 직접 구현한다는 것은 쉽지도 안을뿐더라 순서보장이나 안정성에 대한 부분들 때문이라도 쓰는것이 좋은 방법입니다.
또한 OAuth, JWT, SNS 로그인 등 여러가지 기능구현에 많은 부분들을 보다 쉽게 개발하기 용이하게 해줍니다.
Spring Boot + Jpa + Secruit + Jwt + gradle + Swagger API
기능
- 회원 가입
- 회원 로그인(인증)
- 회원 로그아웃
build.gradle
Gradle 7.2 버전 기준입니다.
dependencies는 기능에 필요한 라이브러리들만 추가하였습니다.
plugins {
id 'org.springframework.boot' version '2.5.6'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
id 'java'
}
group = 'com.idus'
version = '0.0.1'
sourceCompatibility = '11'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
implementation('com.h2database:h2:1.4.196')
runtimeOnly 'mysql:mysql-connector-java'
implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.2'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.2'
testImplementation 'org.springframework.security:spring-security-test'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
implementation "io.springfox:springfox-boot-starter:3.0.0"
implementation "io.springfox:springfox-swagger-ui:3.0.0"
}
test {
useJUnitPlatform()
}
Security Config 설정 코드
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter{
@Autowired
private TokenProvider tokenProvider;
@Autowired
private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
@Autowired
private JwtAccessDeniedHandler jwtAccessDeniedHandler;
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
스프링 시큐리티 별도설정없이 구동해보기
Security를 별도의 설정을 하지 않고 구동 후 브라우저를 접근하시면
ID, Password 를 넣게되는 form 창을 볼수 있습니다.
URL 패턴을 인지하고 Filter를 통해서 기능을 제공하기 때문입니다.
회원 API만 제공하는 Server를 구현한다면 불필요한 부분들을 제어 해야합니다.
URL에 대한 접근제어를 하기위해서는 Security Configuration 설정해야합니다.
Spring Security configuration
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter{
@Autowired
private TokenProvider tokenProvider;
@Autowired
private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
@Autowired
private JwtAccessDeniedHandler jwtAccessDeniedHandler;
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
.and()
.headers()
.frameOptions()
.sameOrigin()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/signin", "/signup", "/reissuance", "/h2/**", "/swagger-ui/**", "/swagger-resources/**").permitAll()
.anyRequest().authenticated()
.and()
.addFilterBefore(new JwtFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class);
}
특정 리소스 접근 허용 또는 특정 권한을 가진 사용자만 접근을 가능하게 할수 있으며 코드에서 보는것처럼
체이닝 형태로 구성할수 있습니다.
authenticationEntryPoint
인증절차를 처리하기 위한 용도 입니다.
인증과정에서 실패하거나 인증해더를 보내지 않게되는경우 보통 401(UnAuthorized)응답값을 받게되는데 이를처리해주는 로직이 바로
"authenticationEntryPoint" 인터페이스 입니다.
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Autowired
private ObjectMapper objectMapper;
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
response.setContentType( MediaType.APPLICATION_JSON_VALUE );
ApiErrorResponse apiErrorResponse = new ApiErrorResponse();
apiErrorResponse.setErrorMessage(authException.getMessage());
apiErrorResponse.setErrorCode(HttpServletResponse.SC_UNAUTHORIZED);
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, objectMapper.writeValueAsString(apiErrorResponse));
}
}
accessDeniedHandler
서버에 요청을 할때 액세스가 가능한지 권한을 체크후 엑세스 할수 없는 요청을 했을때 동작하게됩니다.
.accessDeniedHandler(jwtAccessDeniedHandler)
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Autowired
private ObjectMapper objectMapper;
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.setContentType( MediaType.APPLICATION_JSON_VALUE );
ApiErrorResponse apiErrorResponse = new ApiErrorResponse();
apiErrorResponse.setErrorMessage(accessDeniedException.getMessage());
apiErrorResponse.setErrorCode(HttpServletResponse.SC_FORBIDDEN);
response.sendError(HttpServletResponse.SC_FORBIDDEN, objectMapper.writeValueAsString(apiErrorResponse));
}
}
sessionCreationPolicy
Jwt 토큰방식을 사용할때 Session 정책 설정하는 방식입니다.
4가지의 상수가 존재합니다
sessionCreationPolicy.ALWAYS : 스프링 시큐리티가 항상 Session 생성
sessionCreationPolicy.IF_REQUIRED : 스프링 시큐리티가 필요시 생성 (기본)
sessionCreationPolicy.NEVER : 스프링 시큐리티가 생성하지 않지만 기존에 존자하면 사용
sessionCreationPolicy.STATELESS : 스프링 시큐리티가 생성하지도 않고 기존것을 사용하지도 않음
JWT 사용시 아래코드 상수값을 사용합니다.
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
antMatchers
아래 코드처럼 리소스를 정의하고 .permitAll() 을 선언하면
리소스의 접근을 인증절차 없이 허용한다는 의미입니다.
로그인, 로그아웃 뿐만아니라 Swagger API로 노출되는 URI는 인증이 패스되어야합니다.
h2 database를 사용했기 때문에 h2 관련 리소스도 인증 패스되도록 설정하였습니다.
.antMatchers("/signin", "/signup", "/reissuance", "/h2/**", "/swagger-ui/**", "/swagger-resources/**").permitAll()
hasAnyRole
사용한다면 특정 Role를 가진 사용자만 접근을 허용할수도 있습니다.
antMatchers("/admin/**").hasAnyRole("ADMIN")
addFilterBefore
지정된 필터 앞에 커스텀 필터를 추가 UsernamePasswordAuthenticationFilter 보다 먼저 실행하게됩니다.
JWTFilter ,UsernamePasswordAuthenticationFilter를 실행하게됩니다.
addFilterBefore(new JwtFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class);
JWT 사용을 위한 추가
JwtFilter, TokenProvider, JwtAuthenticationEntryPoint
JwtFilter 를 사용하기 위해서 doFilter를 addFilterBefore 에 설정합니다.
.addFilterBefore(new JwtFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class);
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private final TokenProvider tokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
String jwt = tokenProvider.resolveToken(request);
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
Authentication authentication = tokenProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
}
JwtFilter에는 TokenProvider 구현체가 존재하며 Jwt Token 에 대한 정책 및 비지니스 로직이 구현되어있습니다.
public class TokenProvider {
public static final String ISSUER = "IDUS";
private static final String AUTHORITIES_KEY = "auth";
private static final String BEARER_TYPE = "bearer";
private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30; // 30분
private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7; // 7일
public static final String UID = "uid";
public static final String AUTHORIZATION_HEADER = "Authorization";
@Autowired
private UserService userService;
private final Key key;
public TokenProvider(@Value("${jwt.secret}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
public TokenResponse generateToken(Authentication authentication) {
Map<String, Object> header = new HashMap<>();
header.put("typ", "JWT");
header.put("alg", "HS256");
long now = LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
TokenResponse tokenResponse = this.generateAccessToken(authentication);
Claims claims = Jwts.claims();
claims.setIssuer(ISSUER);
claims.put(UID, ((PrincipalDetails) userDetails).getUser().getId());
claims.setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME));
String refreshToken = Jwts.builder()
.setHeader(header)
.setIssuer(claims.getIssuer())
.setIssuedAt(claims.getIssuedAt())
.setExpiration(claims.getExpiration())
.setClaims(claims)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
tokenResponse.setRefreshToken(refreshToken);
return tokenResponse;
}
public TokenResponse generateAccessToken(Authentication authentication) {
Map<String, Object> header = new HashMap<>();
header.put("typ", "JWT");
header.put("alg", "HS256");
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
Claims claims = Jwts.claims();
claims.setIssuer(ISSUER);
claims.put(AUTHORITIES_KEY, authorities);
claims.put(UID, ((PrincipalDetails) userDetails).getUser().getId());
claims.setExpiration(accessTokenExpiresIn);
String accessToken = Jwts.builder()
.setHeader(header)
.setIssuer(claims.getIssuer())
.setIssuedAt(claims.getIssuedAt())
.setExpiration(claims.getExpiration())
.setClaims(claims)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
return TokenResponse.builder()
.grantType(BEARER_TYPE)
.accessToken(accessToken)
.accessTokenExpiresIn(accessTokenExpiresIn.getTime())
.build();
}
public Authentication getAuthentication(String accessToken) {
Claims claims = parseClaims(accessToken);
if (claims.get(AUTHORITIES_KEY) == null) {
throw new ApiException(ServiceErrorType.UNAUTHORIZED);
}
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
User user = userService.getById(Long.parseLong(String.valueOf(claims.get(UID))));
UserDetails principal = new PrincipalDetails(user);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("잘못된 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
log.info("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
log.info("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException e) {
log.info("JWT 토큰이 잘못되었습니다.");
}
return false;
}
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
public String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_TYPE)) {
return bearerToken.substring(7);
}
return null;
}
}
스프링 시큐리티를 구성하기 위해서는 체이닝 명렁어를 통해서 원하는 형태에 구성을 할수 있습니다.
필터 기능 및 설명이 필요하다면 1편을 참고하세요!!
API Server로만 구성한다면 아래정보들은 필요하지 않습니다.
아래 코드는 로그인 페이지와 기타 로그인 처리 및 성공 실패 처리를 사용하겠다는 의미입니다.
http.formLogin();
아래코드는 사용자가 따로 만든 로그인 페이지를 사용하려고 할때 설정합니다.
loginPage("/login-page")
정상적으로 인증성공 했을 경우 이동하는 페이지를 설정합니다.
defaultSuccessUrl("/main")
정상적인증 성공 후 별도의 처리가 필요한 경우 커스텀 핸들러를 생성하여 등록할 수 있습니다.
successHandler(new CustomAuthenticationSuccessHandler("/main"))
failureUrl 은 인증이 실패 했을 경우 이동하는 페이지를 설정합니다.
failureUrl("/login-fail")
나머지 부분들은 Github를 참고하시면 됩니다.
https://github.com/lswteen/idus-security
Spring Secruit 공식 가이드 Spring.io 정보입니다.
Spring boot 2022.04.04 기준
버전 정보 : 5.6.2
https://spring.io/projects/spring-security#learn
https://docs.spring.io/spring-security/reference/getting-spring-security.html#getting-gradle-boot