Skip to content

Commit

Permalink
✨ Spring Security 초기 설정 (+ Test case 에러 관련) (#22)
Browse files Browse the repository at this point in the history
* chore: external-api 모듈 spring boot starter security 의존성 주입

* chore: security config 설정

* chore: method security config 설정

* fix: 기존 api 인가 권한 is-anonymous로 제한

* fix: security config 인증, 인가 예외 필터 제거 (로그인 작업 시 추가)

* fix: user sync helper oauth 반환 수정

* test: user sync helper 메서드 반환 타입 수정

* test: username 반환 검증 추가

* fix: pennyway infra application @spring boot application 어노테이션 제거

* test: 성공 응답 객체 code 값 2000으로 수정

* chore: spring security test 의존성 주입

* test: auth controller 성공 응답 set cookie 헤더 존재 여부 판단으로 수정

* chore: sub project test 블럭 추가

* feat: security user details & service 정의

* chore: local 환경 내 logging level 정보 추가

* fix: user sync helper transaction 제거
  • Loading branch information
psychology50 authored Mar 27, 2024
1 parent e09ab66 commit 39f97e9
Show file tree
Hide file tree
Showing 11 changed files with 552 additions and 340 deletions.
8 changes: 6 additions & 2 deletions pennyway-app-external-api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ plugins {
id 'java'
}

bootJar {enabled = true}
jar {enabled = false}
bootJar { enabled = true }
jar { enabled = false }

group = 'kr.co'
version = 'unspecified'
Expand All @@ -17,6 +17,10 @@ dependencies {
implementation project(':pennyway-domain')
implementation project(':pennyway-infra')

/* Security */
implementation 'org.springframework.boot:spring-boot-starter-security:3.2.4'
testImplementation 'org.springframework.security:spring-security-test:6.2.3'

/* Swagger */
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.4.0'

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
Expand All @@ -34,21 +35,21 @@ public class AuthController {

@Operation(summary = "인증번호 전송")
@PostMapping("/phone")
// TODO: Spring Security 설정 후 @PreAuthorize("permitAll()") 추가 && ip 당 횟수 제한
@PreAuthorize("isAnonymous()")
public ResponseEntity<?> sendCode(@RequestBody @Validated PhoneVerificationDto.PushCodeReq request) {
return ResponseEntity.ok(SuccessResponse.from("sms", authUseCase.sendCode(request)));
}

@Operation(summary = "인증번호 검증")
@PostMapping("/phone/verification")
// TODO: Spring Security 설정 후 @PreAuthorize("permitAll()") 추가
@PreAuthorize("isAnonymous()")
public ResponseEntity<?> verifyCode(@RequestBody @Validated PhoneVerificationDto.VerifyCodeReq request) {
return ResponseEntity.ok(SuccessResponse.from("sms", authUseCase.verifyCode(request)));
}

@Operation(summary = "일반 회원가입")
@PostMapping("/sign-up")
// TODO: Spring Security 설정 후 @PreAuthorize("isAnonymous()") 추가
@PreAuthorize("isAnonymous()")
public ResponseEntity<?> signUp(@RequestBody @Validated SignUpReq.General request) {
Pair<Long, Jwts> jwts = authUseCase.signUp(request);
ResponseCookie cookie = cookieUtil.createCookie("refreshToken", jwts.getValue().refreshToken(), Duration.ofDays(7).toSeconds());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,31 +14,30 @@
@Helper
@RequiredArgsConstructor
public class UserSyncHelper {
private final UserService userService;

private final UserService userService;
/**
* 일반 회원가입 시 이미 가입된 회원인지 확인
*
* @param phone String : 전화번호
* @return Pair<Boolean, String> : 이미 가입된 회원인지 여부 (TRUE: 가입되지 않은 회원, FALSE: 가입된 회원), 가입된 회원인 경우 회원
* ID 반환
* @throws UserErrorException : 이미 일반 회원가입을 한 유저인 경우
*/
public Pair<Boolean, String> isSignedUserWhenGeneral(String phone) {
User user;
try {
user = userService.readUserByPhone(phone);
} catch (GlobalErrorException e) {
log.info("User not found. phone: {}", phone);
return Pair.of(Boolean.FALSE, null);
}

/**
* 일반 회원가입 시 이미 가입된 회원인지 확인
*
* @param phone String : 전화번호
* @return Pair<Boolean, String> : 이미 가입된 회원인지 여부 (TRUE: 가입되지 않은 회원, FALSE: 가입된 회원), 가입된 회원인 경우 회원
* ID 반환
* @throws UserErrorException : 이미 일반 회원가입을 한 유저인 경우
*/
public Pair<Boolean, String> isSignedUserWhenGeneral(String phone) {
User user;
try {
user = userService.readUserByPhone(phone);
} catch (GlobalErrorException e) {
log.info("User not found. phone: {}", phone);
return Pair.of(Boolean.FALSE, null);
}
if (user.getPassword() != null) {
log.warn("User already exists. phone: {}", phone);
throw new UserErrorException(UserErrorCode.ALREADY_SIGNUP);
}

if (user.getPassword() != null) {
log.warn("User already exists. phone: {}", phone);
throw new UserErrorException(UserErrorCode.ALREADY_SIGNUP);
return Pair.of(Boolean.TRUE, user.getUsername());
}

return Pair.of(Boolean.TRUE, user.getUsername());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package kr.co.pennyway.api.common.security.authentication;

import com.fasterxml.jackson.annotation.JsonIgnore;
import kr.co.pennyway.domain.domains.user.domain.User;
import kr.co.pennyway.domain.domains.user.type.Role;
import lombok.Builder;
import lombok.Getter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Arrays;
import java.util.Collection;

@Getter
public class SecurityUserDetails implements UserDetails {
private final Long userId;
private final String username;
private final Collection<? extends GrantedAuthority> authorities;
private final boolean accountNonLocked;

@JsonIgnore
private boolean enabled;
@JsonIgnore
private String password;
@JsonIgnore
private boolean credentialsNonExpired;
@JsonIgnore
private boolean accountNonExpired;

@Builder
private SecurityUserDetails(Long userId, String username, Collection<? extends GrantedAuthority> authorities, boolean accountNonLocked) {
this.userId = userId;
this.username = username;
this.authorities = authorities;
this.accountNonLocked = accountNonLocked;
}

public static UserDetails from(User user) {
return SecurityUserDetails.builder()
.userId(user.getId())
.username(user.getUsername())
.authorities(Arrays.stream(Role.values())
.filter(roleType -> roleType == user.getRole())
.map(roleType -> (GrantedAuthority) roleType::getType)
.toList())
.accountNonLocked(user.getLocked())
.build();
}

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}

@Override
public String getPassword() {
throw new UnsupportedOperationException();
}

@Override
public String getUsername() {
return username;
}

@Override
public boolean isAccountNonExpired() {
throw new UnsupportedOperationException();
}

@Override
public boolean isAccountNonLocked() {
return accountNonLocked;
}

@Override
public boolean isCredentialsNonExpired() {
throw new UnsupportedOperationException();
}

@Override
public boolean isEnabled() {
throw new UnsupportedOperationException();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package kr.co.pennyway.api.common.security.authentication;

import kr.co.pennyway.domain.domains.user.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.Cacheable;
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.Service;

@Service
@RequiredArgsConstructor
public class UserDetailServiceImpl implements UserDetailsService {
private final UserService userService;

@Override
@Cacheable(value = "securityUser", key = "#userId", unless = "#result == null", cacheManager = "securityUserCacheManager")
public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
try {
return SecurityUserDetails.from(userService.readUser(Long.parseLong(userId)));
} catch (Exception e) {
return null;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package kr.co.pennyway.api.config.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler;
import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;

@Configuration
@EnableMethodSecurity(securedEnabled = true)
public class MethodSecurityConfig {
@Bean
protected MethodSecurityExpressionHandler createExpressionHandler() {
DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler();
return expressionHandler;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package kr.co.pennyway.api.config.security;

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.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.Arrays;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private static final String[] publicReadOnlyPublicEndpoints = {
"/favicon.ico",
// Swagger
"/api-docs/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger",
};

@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.cors((cors) -> cors.configurationSource(corsConfigurationSource()))
.formLogin(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
.sessionManagement((sessionManagement) ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(
auth -> auth.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
.requestMatchers(HttpMethod.OPTIONS, "*").permitAll()
.requestMatchers(HttpMethod.GET, publicReadOnlyPublicEndpoints).permitAll()
.anyRequest().permitAll()
);
return http.build();
}

// TODO: dev, test, prod 환경이 정해지면 수정 필요.
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.addAllowedOrigin("http://localhost:3000");
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "OPTIONS", "PUT", "PATCH", "DELETE"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
Loading

0 comments on commit 39f97e9

Please sign in to comment.