pom.xml
<!-- security & jwt -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<!-- // -->
-장정우님의 스프링 부트 핵심 가이드라는 책을 보고 정리하였습니다.-
1. 클라이언트로부터 요청을 받으면 서블릿 필터에서 SecurityFilterChain으로 작업이 위임되고
그중 UsernamePasswordAuthenticationFilter(위 그림에서는 AuthenticationFilter에 해당한다.)에서 인증을 처리한다.
2. AuthenticationFilter는 요청 객체(HttpServletRequest)에서 username과 password를 추출해서 토큰을 생성한다.
3. 그러고 나서 AuthenticationManager에게 토큰을 전달합니다.
AuthenticationManager는 인터페이스이며, 일반적으로 사용되는 구현체는 ProviderManager입니다.
4. ProviderManager는 인증을 위해 AuthenticationProvider로 토큰을 전달합니다.
5. AuthenticationProvider는 토큰의 정보를 UserDetailsService에 전달합니다.
6. UserDetailsService는 전달받은 정보를 통해 데이터베이스에서 일치하는 사용자를 찾아 UserDetails 객체를 생성합니다.
7. 생성된 UserDetails 객체는 AuthenticationProvider로 전달되며, 해당 Provider에서 인증을 수행하고 성공하게되면
ProviderManager로 권한을 담은 토큰을 전달합니다.
8. ProviderManager는 검증된 토큰을 AuthenticationFilter로 전달합니다.
9. AuthenticationFilter는 검증된 토큰을 SecurityContextHolder에 있는 SecurityContext에 저장합니다.
1. User엔티티를 생성하고 UserDetails를 구현한다.
package com.springboot.security.data.entity;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.*;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import javax.persistence.*;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
@Entity
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
@Builder
@Table
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String uid;
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String name;
@ElementCollection(fetch = FetchType.EAGER)
@Builder.Default
private List<String> roles = new ArrayList<>();
/**
* 권한목록
* @return
*/
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.roles.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
}
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public String getUsername() {
return this.uid;
}
/**
* 계정만료 여부
* @return
*/
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public boolean isAccountNonExpired() {
return true;
}
/**
* 계정잠김 여부
* @return
*/
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public boolean isAccountNonLocked() {
return true;
}
/**
* 비밀번호 만료 여부
* @return
*/
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public boolean isCredentialsNonExpired() {
return true;
}
/**
* 계정활성화 여부
* @return
*/
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public boolean isEnabled() {
return true;
}
}
2. UserRepository를 생성한다.
package com.springboot.security.data.repository;
import com.springboot.security.data.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
User findByUid(String uid);
}
3. UserDetailService와 Impl을 구현한다
package com.springboot.security.service;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
public interface UserDetailService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
package com.springboot.security.service.impl;
import com.springboot.security.data.repository.UserRepository;
import com.springboot.security.service.UserDetailService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Slf4j
@Service
public class UserDetailServiceImpl implements UserDetailService {
private final UserRepository userRepository;
public UserDetailServiceImpl(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
log.info("[loadUserByUsername] loadUserByUsername 수행. username : {}", username);
return userRepository.findByUid(username);
}
}
4. JwtTokenProvider를 만든다.
package com.springboot.security.config.security;
import com.springboot.security.service.UserDetailService;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletRequest;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Date;
import java.util.List;
@Slf4j
@Component
public class JwtTokenProvider {
private final UserDetailService userDetailService;
@Autowired
public JwtTokenProvider(UserDetailService userDetailService) {
this.userDetailService = userDetailService;
}
@Value("${springboot.jwt.secret}")
private String secretKey = "secretKey";
private final long tokenValidMillisecond = 1000L * 60 * 60;
@PostConstruct
protected void init() {
log.info("[init] JwtTokenProvider 내 secretKey 초기화 시작");
System.out.println(secretKey);
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes(StandardCharsets.UTF_8));
System.out.println(secretKey);
log.info("[init] JwtTokenProvider 내 secretKey 초기화 완료");
}
public String createToken(String userUid, List<String> roles) {
log.info("[createToken] 토큰 생성 시작");
Claims claims = Jwts.claims().setSubject(userUid);
claims.put("roles", roles);
Date now = new Date();
String token = Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + tokenValidMillisecond))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
log.info("[createToken] 토큰 생성 완료");
return token;
}
public Authentication getAuthentication(String token) {
log.info("[getAuthentication] 토큰 인증 정보 조회 시작");
UserDetails userDetails = userDetailService.loadUserByUsername(this.getUsername(token));
log.info("[getAuthentication] 토큰 인증 정보 조회 완료, UserDetails UserName : {}", userDetails.getUsername());
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
public String getUsername(String token) {
log.info("[getUsername] 토큰 기반 회원 구별 정보 추출");
String info = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
log.info("[getUsername] 토큰기반 회원 구별 정보 추출 완료, info : {}", info);
return info;
}
public String resolveToken(HttpServletRequest request) {
log.info("[resolveToken] HTTP 헤더에서 Token 값 추출");
return request.getHeader("X-AUTH-TOKEN");
}
public boolean validateToken(String token) {
log.info("[validateToken] 토큰 유효 체크 시작");
try {
Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
return !claims.getBody().getExpiration().before(new Date());
} catch (Exception e) {
log.info("[validateToken] 토큰 유효 체크 예외 발생");
return false;
}
}
}
5. OnecePerRequestFilter를 상속받은 JwtAuthenticationFilter 클래스를 만들어준다.
OnecePerRequestFilter 대신 GenericFilterBean을 사용할수 있지만 RequestDispatcher라는 놈에 의해 다른 서블릿으로 디스패치 되면서 필터가 두번 실행되는 현상이 발생할수 있다고 한다.(이같은 문제를 해결한게 OnecePerRequestFilter이다.)
package com.springboot.security.config.security;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
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;
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = jwtTokenProvider.resolveToken(request);
log.info("[doFilterInternal] token 값 추출 완료. token : {}", token);
log.info("[doFilterInternal] token 값 유효성 체크 시작");
if(token != null && jwtTokenProvider.validateToken(token)) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.info("[doFilterInternal] token 값 유효성 체크 완료");
}
filterChain.doFilter(request, response);
}
}
6. SecurityConfiguration을 생성하여 필요한 설정을 해준다.
과거에는 WebSecurityConfigurerAdapter를 상속했지만 deprecated되어서
구현방법이 살짝 바뀌었다.
package com.springboot.security.config.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
public class SecurityConfiguration {
private final JwtTokenProvider jwtTokenProvider;
public SecurityConfiguration(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
//UI 비활성화
.httpBasic().disable()
//Rest API에서는 CSRF 보안이 필요없어서 비활성화
.csrf().disable()
//JWT 토큰으로 인증을 처리하며, 세션은 사용하지 않기 때문에 STATELESS 설정
.sessionManagement()
.sessionCreationPolicy(
SessionCreationPolicy.STATELESS
)
.and()
//들어오는 요청에대해 사용권한을 체크한다
.authorizeHttpRequests()
//아래 경로에 대해서는 모두에게 허용한다
.antMatchers("/sign-api/sign-in", "/sign-api/sign-up", "/sign-api/exception").permitAll()
//product로 시작하는 경로의 GET 요청은 모두 허용한다
.antMatchers(HttpMethod.GET, "/product/**").permitAll()
//exception 단어가 들어간 모든 경로를 허용한다
.antMatchers("**exception**").permitAll()
.anyRequest().hasRole("ADMIN")
.and()
//권한을 확인하는 과정에서 통과하지 못하는 예외가 발생할 경우 예외를 전달한다
.exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler())
.and()
//인증과정에서 예외가 발생할 경우 예외를 전달한다
.exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint())
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public WebSecurityCustomizer configure() { //인증과 인가가 모두 적용되기전에 동작하는 설정이다
return web -> web.ignoring()
.antMatchers("/api-docs/**","/swagger-ui/**" ,"/sign-api/exception");//인증, 인가를 무시하는 경로를 설정한다
}
}
7. AccessDeniedHandler / AuthenticationEntryPoint 구현
package com.springboot.security.config.security;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Slf4j
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException, ServletException {
log.info("[handle] 접근이 막혔을 경우 경로 리다이렉트");
response.sendRedirect("/sign-api/exception");
}
}
package com.springboot.security.config.security;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class EntryPointErrorResponse {
private String msg;
}
package com.springboot.security.config.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Slf4j
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
//굳이 메시지를 설정할 필요가 없다면 맨아래 인증실패 코드만 전달하면 된다
ObjectMapper objectMapper = new ObjectMapper();
log.info("[commence] 인증 실패로 response.sendError 발생");
EntryPointErrorResponse entryPointErrorResponse = new EntryPointErrorResponse();
entryPointErrorResponse.setMsg("인증이 실패하였습니다.");
response.setStatus(401);
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().write(objectMapper.writeValueAsString(entryPointErrorResponse));
//
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
Security 설정은 끝났다
회원가입과 로그인 구현방법
1. SignUpResultDto / SignInResultDto 생성
package com.springboot.security.data.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class SignUpResultDto {
private boolean success;
private int code;
private String msg;
}
package com.springboot.security.data.dto;
import lombok.*;
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class SignInResultDto extends SignUpResultDto{
private String token;
@Builder
public SignInResultDto(boolean success, int code, String msg, String token) {
super(success, code, msg);
this.token = token;
}
}
2. PasswordEncoderConfiguration
package com.springboot.security.config.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class PasswordEncoderConfiguration {
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
3. CommonResponse
package com.springboot.security.config.security;
public enum CommonResponse {
SUCCESS(0, "Success"), FAIL(-1, "Fail");
int code;
String msg;
CommonResponse(int code, String msg) {
this.code = code;
this.msg = msg;
}
public int getCode() {
return code;
}
public String getMsg() {
return msg;
}
}
4. SignService / SignServiceImpl
package com.springboot.security.service;
import com.springboot.security.data.dto.SignInResultDto;
import com.springboot.security.data.dto.SignUpResultDto;
public interface SignService {
SignUpResultDto signUp(String id, String password, String name, String role);
SignInResultDto signIn(String id, String password) throws RuntimeException;
}
package com.springboot.security.service.impl;
import com.springboot.security.config.security.CommonResponse;
import com.springboot.security.config.security.JwtTokenProvider;
import com.springboot.security.data.dto.SignInResultDto;
import com.springboot.security.data.dto.SignUpResultDto;
import com.springboot.security.data.entity.User;
import com.springboot.security.data.repository.UserRepository;
import com.springboot.security.service.SignService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collections;
@Service
@Slf4j
@Transactional
public class SignServiceImpl implements SignService {
private final UserRepository userRepository;
private final JwtTokenProvider jwtTokenProvider;
private final PasswordEncoder passwordEncoder;
@Autowired
public SignServiceImpl(UserRepository userRepository, JwtTokenProvider jwtTokenProvider, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.jwtTokenProvider = jwtTokenProvider;
this.passwordEncoder = passwordEncoder;
}
@Override
public SignInResultDto signIn(String id, String password) throws RuntimeException {
log.info("[getSignInResult] signDataHandler 로 회원 정보 요청");
User user = userRepository.findByUid(id);
log.info("[getSignInResult] Id : {}", id);
log.info("[getSignInResult] 패스워드 비교 수행");
if (!passwordEncoder.matches(password, user.getPassword())) {
log.info("[getSignInResult] 패스워드 불일치");
throw new RuntimeException();
}
log.info("[getSignInResult] 패스워드 일치");
log.info("[getSignInResult] SignInResultDto 객체 생성");
SignInResultDto signInResultDto = SignInResultDto.builder()
.token( jwtTokenProvider.createToken( String.valueOf( user.getUid() ), user.getRoles() ) )
.build();
log.info("[getSignInResult] SignInResultDto 객체에 값 주입");
setSuccessResult(signInResultDto);
return signInResultDto;
}
@Override
public SignUpResultDto signUp(String id, String password, String name, String role) {
log.info("[getSignUpResult] 회원 가입 정보 전달");
User user;
if (role.equalsIgnoreCase("admin")) {
user = User.builder()
.uid(id)
.name(name)
.password(passwordEncoder.encode(password))
.roles(Collections.singletonList("ROLE_ADMIN"))
.build();
} else {
user = User.builder()
.uid(id)
.name(name)
.password(passwordEncoder.encode(password))
.roles(Collections.singletonList("ROLE_USER"))
.build();
}
User savedUser = userRepository.save(user);
SignUpResultDto signUpResultDto = new SignInResultDto();
log.info("[getSignUpResult] userEntity 값이 들어왔는지 확인 후 결과값 주입");
if (!savedUser.getName().isEmpty()) {
log.info("[getSignUpResult] 정상 처리 완료");
setSuccessResult(signUpResultDto);
} else {
log.info("[getSignUpResult] 실패 처리 완료");
setFailResult(signUpResultDto);
}
return signUpResultDto;
}
private void setSuccessResult(SignUpResultDto signUpResultDto) {
signUpResultDto.setSuccess(true);
signUpResultDto.setCode(CommonResponse.SUCCESS.getCode());
signUpResultDto.setMsg(CommonResponse.SUCCESS.getMsg());
}
private void setFailResult(SignUpResultDto signUpResultDto) {
signUpResultDto.setSuccess(false);
signUpResultDto.setCode(CommonResponse.FAIL.getCode());
signUpResultDto.setMsg(CommonResponse.FAIL.getMsg());
}
}
5. SignController
package com.springboot.security.controller;
import com.springboot.security.data.dto.SignInResultDto;
import com.springboot.security.data.dto.SignUpResultDto;
import com.springboot.security.service.SignService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/sign-api")
@Tag(name = "Sign", description = "security login")
public class SignController {
private final SignService signService;
public SignController(SignService signService) {
this.signService = signService;
}
@PostMapping(value = "/sign-in")
@Operation(summary = "SignIn", description = "로그인")
public SignInResultDto signIn(@RequestParam String id,
@RequestParam String password) throws RuntimeException {
log.info("[signIn] 로그인을 시도하고 있습니다. id : {}, pw : ****", id);
SignInResultDto signInResultDto = signService.signIn(id, password);
if (signInResultDto.getCode() == 0) {
log.info("signIn] 정상적으로 로그인 되었습니다. id : {}, token : {}", id, signInResultDto.getToken());
}
return signInResultDto;
}
@PostMapping(value = "/sign-up")
@Operation(summary = "SignUp", description = "회원가입")
public SignUpResultDto signUp(@RequestParam String id, @RequestParam String password,
@RequestParam String name, @RequestParam String role) {
log.info("[singUp] 회원가입을 수행합니다 id : {}, password : ****, name : {}, role : {}", id, name, role);
SignUpResultDto signUpResultDto = signService.signUp(id, password, name, role);
log.info("[signUp] 회원가입을 완료했습니다. id : {}", id);
return signUpResultDto;
}
@GetMapping(value = "/exception")
public void exceptionTest() throws RuntimeException {
throw new RuntimeException("접근이 금지되었습니다.");
}
@ExceptionHandler(value = RuntimeException.class)
public ResponseEntity<Map<String, String>> ExceptionHandler(RuntimeException e) {
HttpHeaders responseHeaders = new HttpHeaders();
// responseHeaders.add(HttpHeaders.CONTENT_TYPE, "application/json");
HttpStatus httpStatus = HttpStatus.BAD_REQUEST;
log.error("ExceptionHandler 호출, {}, {}", e.getCause(), e.getMessage());
Map<String, String> map = new HashMap<>();
map.put("error type", httpStatus.getReasonPhrase());
map.put("code", "400");
map.put("message", "에러 발생");
return new ResponseEntity<>(map, responseHeaders, httpStatus);
}
}
-장정우님의 스프링 부트 핵심 가이드라는 책을 보고 정리하였습니다.-
'프로그래밍 > Spring Boot' 카테고리의 다른 글
Spring Security Architecture (1) | 2023.11.11 |
---|