단일 서버로 구성된 프로젝트에서의 spring security 사용과 jwt, redis 사용
- 단일 서버에서 진행하는 spring security authentication 과정과 jwt 적용 그리고 상태 유지를 위한 redis 저장 작업입니다.
- spring security - authentication 확인
- 확인된 인증 사용자라면? jwt 발급, 해당 jwt를 redis 저장
- 확인되지 않은 사용자라면? 다시 회원 로그인 시도하게 하기
분산 서버라면?
- 조금 더 복잡해진다.
- 예를 들어 Front(사용자 요청을 받는 곳, 사용자에게 정보를 노출하는 곳), Auth(사용자의 인증, 인가 작업을 진행하는 곳), API(Rest API를 담당하는 곳) 서버로 나뉜 MSA 구조로 해당 프로젝트를 진행한다고 하고 설명을 하자면...
- front server(client 요청을 받는 서버)에서 http request가 넘어오면 해당 정보를 auth server로 넘겨 해당 인증 작업을 위임해야 한다.
- auth server에서 해당 요청 정보를 가지고 인증된 객체라면 jwt발급과 redis 저장 작업을 진행해준다.
- 이때 API server를 따로 두고 해당 회원 정보를 가진 DB를 이곳에서 관리하면 auth server는 userDetailsService작업을 진행할 때 해당 회원 정보를 API server에게 요청해 받아와야 한다.
- 또한 Front 서버에서는 Interceptor 작업으로 인가가 필요한 모든 작업에 해당 인가 확인 인터셉터를 등록해서 사용해야 한다.(jwt 토큰 재발급도 이렇게 가능)
- API 서버에 어떤 요청을 보낼 때 Front서버에서는 인가와 관련된 header 설정을 진행해서 해당 API 사용시에 인가 정보를 넘겨줄 수 있게 한다.
NoSuchMethodError
에러 발생) <!--jjwt java json web token-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
</dependency>
<!--redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</dependency>
import com.spring.security.practice.springsecuritypractice.auth.jwt.JwtProvider;
import com.spring.security.practice.springsecuritypractice.member.exception.InvalidLoginRequestException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
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.Date;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.stream.Collectors;
@Slf4j
public class CustomAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/auth/login", "POST");
private boolean postOnly = true;
private final AuthenticationManager authenticationManager;
private final JwtProvider jwtProvider;
private static final String AUTHENTICATION = "Authentication ";
private static final String PREFIX_BEARER = "Bearer ";
private static final String EXPIRE = "Expire ";
public CustomAuthenticationFilter(AuthenticationManager authenticationManager, JwtProvider jwtProvider) {
super(DEFAULT_ANT_PATH_REQUEST_MATCHER);
this.authenticationManager = authenticationManager;
this.jwtProvider = jwtProvider;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
String loginId = request.getParameter("loginId");
String password = request.getParameter("password");
if (!this.postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
else if (Objects.isNull(loginId) || Objects.isNull(password) || loginId.isBlank() || password.isBlank()){
throw new InvalidLoginRequestException();
}
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginId, password);
return authenticationManager.authenticate(authenticationToken);
}
}
AbstractAuthenticationProcessingFilter
를 상속 받아 사용해도 무방하다 import com.spring.security.practice.springsecuritypractice.auth.filter.CustomAuthenticationFilter;
import com.spring.security.practice.springsecuritypractice.auth.filter.JwtAuthenticationFilter;
import com.spring.security.practice.springsecuritypractice.auth.jwt.JwtAuthenticationProvider;
import com.spring.security.practice.springsecuritypractice.auth.jwt.JwtProvider;
import com.spring.security.practice.springsecuritypractice.auth.jwt.JwtFailureHandler;
import com.spring.security.practice.springsecuritypractice.member.service.UserDetailsServiceImpl;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
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.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import javax.annotation.Resource;
/**
* 스프링 시큐리티와 관련해서 환경 설정을 진행하는 클래스입니다.
*/
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfiguration {
@Resource(name = "jwtProvider")
private final JwtProvider jwtProvider;
private final RedisTemplate<String,Object> redisTemplate;
private final UserDetailsServiceImpl userDetailsService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf().disable();
http.cors().disable();
http.formLogin()
.loginPage("/members/login")
.usernameParameter("loginId")
.usernameParameter("password")
.successForwardUrl("/");
http.headers().frameOptions().sameOrigin();
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
http.addFilterAt(customAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
private AbstractAuthenticationProcessingFilter customAuthenticationFilter() throws Exception {
CustomAuthenticationFilter customAuthenticationFilter = new CustomAuthenticationFilter(authenticationManager(null), jwtProvider);
//Filter 적용 url
customAuthenticationFilter.setFilterProcessesUrl("/auth/login");
//인증 실패시 작동할 FailurHandler
customAuthenticationFilter.setAuthenticationFailureHandler(authenticationFailureHandler());
return customAuthenticationFilter;
}
@Bean
public AuthenticationFailureHandler authenticationFailureHandler() {
return new JwtFailureHandler();
}
@Bean
public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
AuthenticationManagerBuilder authenticationManagerBuilder =
http.getSharedObject(AuthenticationManagerBuilder.class);
authenticationManagerBuilder.authenticationProvider(authenticationProvider());
return authenticationManagerBuilder.build();
}
@Bean
public AuthenticationProvider authenticationProvider() {
return new JwtAuthenticationProvider(userDetailsService, bCryptPasswordEncoder());
}
/**
* 비밀번호 평문 저장을 방지하기 위한 엔코더 빈등록
* */
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder(){
return new BCryptPasswordEncoder();
}
}
Authentication
객체로 만들어서 다음 작업으로 해당 객체를 넘겨준다. @Bean
public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
AuthenticationManagerBuilder authenticationManagerBuilder =
http.getSharedObject(AuthenticationManagerBuilder.class);
authenticationManagerBuilder.authenticationProvider(authenticationProvider());
return authenticationManagerBuilder.build();
}
import com.spring.security.practice.springsecuritypractice.member.domain.entity.Member;
import com.spring.security.practice.springsecuritypractice.member.persistence.inter.QueryMemberRepository;
import com.spring.security.practice.springsecuritypractice.member.persistence.inter.QueryMemberRoleRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
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.Repository;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
@RequiredArgsConstructor
@Repository
public class UserDetailsServiceImpl implements UserDetailsService {
private final QueryMemberRepository queryMemberRepository;
private final QueryMemberRoleRepository queryMemberRoleRepository;
/**
* @param username http request에서 넘어온 회원 정보로 이 정보를 이용해서 db에 실제 데이터가 있는지를 찾아온다.
* */
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Member member = queryMemberRepository.findMemberByLoginId(username);
List<String> roles = queryMemberRoleRepository.findMemberRoleByMemberLoginId(username);
if (Objects.isNull(member)){
throw new RuntimeException("not found member");
}
User user = new User(member.getLoginId(),
member.getPassword(),
roles.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList())
);
return user;
}
}
package com.spring.security.practice.springsecuritypractice.member.persistence.impl;
import com.querydsl.core.types.Projections;
import com.querydsl.jpa.impl.JPAQueryFactory;
import com.spring.security.practice.springsecuritypractice.member.domain.dto.response.MemberResponse;
import com.spring.security.practice.springsecuritypractice.member.domain.entity.Member;
import com.spring.security.practice.springsecuritypractice.member.domain.entity.QMember;
import com.spring.security.practice.springsecuritypractice.member.persistence.inter.QueryMemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.lang.reflect.Constructor;
@Repository
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class QueryMemberRepositoryImpl implements QueryMemberRepository {
private final JPAQueryFactory jpaQueryFactory;
@Override
public Member findMemberByLoginId(String loginId) {
QMember member = QMember.member;
return jpaQueryFactory.selectFrom(member).where(member.loginId.eq(loginId))
.fetchOne();
}
}
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import com.spring.security.practice.springsecuritypractice.member.domain.entity.QMemberRole;
import com.spring.security.practice.springsecuritypractice.member.persistence.inter.QueryMemberRoleRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
@RequiredArgsConstructor
public class QueryMemberRoleRepositoryImpl implements QueryMemberRoleRepository {
private final JPAQueryFactory jpaQueryFactory;
@Override
public List<String> findMemberRoleByMemberLoginId(String loginId) {
QMemberRole memberRole = QMemberRole.memberRole;
return jpaQueryFactory.select(memberRole.role.roleName)
.from(memberRole).where(memberRole.member.loginId.eq(loginId)).fetch();
}
}
import com.spring.security.practice.springsecuritypractice.member.service.UserDetailsServiceImpl;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.util.Objects;
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationProvider implements AuthenticationProvider {
private final UserDetailsServiceImpl userDetailsService;
private final BCryptPasswordEncoder passwordEncoder;
@Override
public Authentication authenticate(Authentication auth) throws AuthenticationException {
String loginId = auth.getName();
String password = (String) auth.getCredentials();
UserDetails user = userDetailsService.loadUserByUsername(loginId);
if(Objects.isNull(user)) {
throw new BadCredentialsException("user id not found!");
}
else if (!this.passwordEncoder.matches(password, user.getPassword())){
throw new BadCredentialsException("password is not matches");
}
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
loginId,
null,
user.getAuthorities()
);
return authenticationToken;
}
@Override
public boolean supports(Class<?> authentication) {
return true;
}
}
@Override
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain,
Authentication authResult) throws IOException, ServletException {
log.info("=============================== successful authentication =================================");
List<String> roles = authResult.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList());
String loginId = authResult.getName();
final String USER_UUID = UUID.randomUUID().toString();
String accessToken = jwtProvider.createAccessToken(loginId, roles);
String refreshToken = jwtProvider.createRefreshToken(loginId, roles);
Date date = jwtProvider.extractExpiredTime(accessToken);
response.addHeader(AUTHENTICATION, PREFIX_BEARER+accessToken);
response.addHeader(EXPIRE, date.toString());
response.addHeader(UUID_HEADER.getValue(), USER_UUID);
}
package com.spring.security.practice.springsecuritypractice.auth.jwt;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Date;
import java.util.List;
@Component
public class JwtProvider {
private static final long ACCESS_TOKEN_EXPIRED_TIME = 1000L * 60 * 60; // 1시간
private static final long REFRESH_TOKEN_EXPIRED_TIME = 1000L * 60L * 60L * 24L * 7L; // 7일
@Value("${jwt.secretKey}")
private String jwtSecretKey;
private Key getSecretKey() {
byte[] keyBytes = jwtSecretKey.getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes);
}
public String createToken(String loginId, List<String> roles, long expiredTime){
Claims claims = Jwts.claims().setSubject(loginId);
claims.put("roles", roles);
Date date = new Date();
Key secretKey = getSecretKey();
String jwt = Jwts.builder()
.setSubject(loginId)
.setIssuedAt(date)
.setExpiration(new Date(date.getTime() + expiredTime))
.signWith(SignatureAlgorithm.HS512, secretKey)
.compact();
return jwt;
}
public String createAccessToken(String loginId, List<String> roles){
return createToken(loginId, roles, ACCESS_TOKEN_EXPIRED_TIME);
}
public String createRefreshToken(String loginId, List<String> roles){
return createToken(loginId, roles, REFRESH_TOKEN_EXPIRED_TIME);
}
public String extractLoginId(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSecretKey())
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
}
public Date extractExpiredTime(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSecretKey())
.build()
.parseClaimsJws(token)
.getBody()
.getExpiration();
}
}
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import lombok.Getter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisClusterConfiguration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
@Getter
public class RedisConfiguration {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private int port;
@Value("${spring.redis.password}")
private String password;
@Value("${spring.redis.database}")
private int database;
/**
* Redis Connection 설정 bean
* @return 레디스 설정을 한 레디스 구현체 Lettuce 입니다.
* */
@Bean
public RedisConnectionFactory redisConnectionFactory(){
RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration();
configuration.setHostName(this.host);
configuration.setPort(this.port);
configuration.setDatabase(this.database);
configuration.setPassword(this.password);
return new LettuceConnectionFactory(configuration);
}
@Bean
public RedisTemplate<String, Object> redisTemplate(){
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer(objectMapper()));
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer(objectMapper()));
return redisTemplate;
}
/**
* ObjectMapper에서 LocalDateTime 관련 에러가 나지 않게 설정해준 뒤 빈으로 등록해서 사용
* */
@Bean
public ObjectMapper objectMapper(){
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.disable(SerializationFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS);
objectMapper.registerModules(new JavaTimeModule(),
new Jdk8Module());
return objectMapper;
}
}
해당 작업은 인증과 관련된 작업!!!!!!! 인가 관련 작업은 다음에 진행