스프링부트 - 코틀린 - 스프링 시큐리티를 사용해서 회원가입, 로그인 기능을 구현한 내용을 기록해둔다.
상세한 내용들은 워딩만 알면 구글링을 하든 GPT에게 물어보든 얼마든지 알 수 있다. 여기서는 전체적인 개념과 작동원리가 어떻게 되는지 개략적으로 살펴본다.
1. 스프링 시큐리티 회원가입, 로그인 기능 추가
라이브러리
상기 언급한 스택에서 기본적인 라이브러리는 설치한다. spring-security는 3.1.5 버전을 사용했다.
implementation("org.springframework.boot:spring-boot-starter-security:3.1.5")
목표
JWT 토큰을 활용한 회원가입/로그인 처리를 한다. Refresh 토큰은 처리하지 않는다. 나중에 migration 해도 된다.
Role도 List 형태로 여러 개를 줄 수 있는데, 편의상 1개 Role로 고정하여 처리했다.
User Entity
시큐리티에서 사용하는 User 정보가 User 라는 class를 활용하기 때문에 보통 Member나 Account라는 이름을 사용한다고 한다. 나의 경우 user가 직관적이고 import 구문으로 구분하면 된다고 생각하여 그냥 User Entity로 처리했다. 상속받는 BaseAllEntityAbstract에는 @CreatedDate, @LastModifiedDate, @CreatedBy, @LastModifiedBy 등 기본적으로 DB 로우 생성 시 있으면 좋은 컬럼들이 담겨져있다.
중요한 부분은 email, password, role 정도이다.
@DynamicUpdate
@Entity
@Table(name = "user")
@Where(clause = "deleted = false")
@SQLDelete(sql = "UPDATE user SET deleted = true WHERE id = ?")
class User(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
var id: Long? = null,
@Column(name = "device_id")
var deviceId: String? = null,
@Column(name = "uuid", nullable = false)
var uuid: String = "",
@Column(name = "name", nullable = false)
var name: String,
@Column(name = "nickname")
var nickname: String? = null,
@Column(name = "email", nullable = false)
var email: String,
@Column(name = "password", columnDefinition = "TEXT")
var password: String? = null,
@Enumerated(EnumType.STRING)
@Column(name = "auth_provider")
var authProvider: AuthProvider = AuthProvider.NONE,
@Column(name = "o_auth_id", columnDefinition = "TEXT")
var oAuthId: String? = null,
@Column(name = "is_active", nullable = false, columnDefinition = "BOOLEAN default true")
var isActive: Boolean = true,
@Enumerated(EnumType.STRING)
@Column(name = "role", nullable = false)
var role: UserRole = UserRole.USER,
) : BaseAllEntityAbstract() {
}
SecurityConfig
여기서 모든 설정을 처리한다. 각 부분에 주석으로 설명을 써놨다.
@Configuration
@EnableWebSecurity
class SecurityConfig {
// JWT 토큰을 생성할 때 포함시킬 비밀값.
// application.yml에 선언해주고 실행 시 환경변수값을 지정해줘야한다.
// SHA256의 경우 8bit * 32라서 32글자 이상의 값으로 지정해줘야 에러가 나지 않는다.
@Value("\${jwt.secretKey}")
private val secretKey: String = ""
//안써줘도 일단 상관없는 내용이다.
//userDetailsService에 어떤 사용자와 비밀번호 형태로 유저 정보를 정의할 것인지를 정의할 수 있다.
//자세한건 다른 소스들을 참고해보면 된다.
//여기서는 이렇게 설정하지 않으면 시큐리티가 기본 비밀번호를 생성해서 콘솔에
//Using generated security password: ...
//형태로 알려주기 때문에, 그냥 InMemory쪽에 저장해놓고 시큐리티가 굳이 생성하지 않도록 막는 역할만 하게했다.
@Bean
fun userDetailsService(): UserDetailsService {
val user = User.withUsername("user")
.password(passwordEncoder().encode("password"))
.roles("USER")
.build()
return InMemoryUserDetailsManager(user)
}
// 비밀번호 Encoder를 BCryptPasswordEncoder로 지정한다.
@Bean
fun passwordEncoder() = BCryptPasswordEncoder()
// 시큐리티는 스프링의 여러 filter 중 DelegatingFilterProxy를 이용하여
// 필터 중간에 껴서 인증 처리를 한다.
// 예를 들어 로그를 남기는 로깅 필터 이후 DelegatingFilterProxy가 있다면
// 여기서 시큐리티가 끼여들어서 인증 처리를 하는 것이다.
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
http.invoke {
authorizeRequests {
authorize("/login/**", permitAll) //로그인은 권한 상관없이 모두 가능
authorize("/swagger-ui/**", permitAll)
// POST로 정보를 생성하고 싶다면 ADMIN 권한을 갖고 있어야함
authorize(HttpMethod.POST, "/api/v1/spaces/**", hasAuthority("ADMIN"))
}
// CORS 설정. 꽤 중요해서 아래쪽에 다시 기술
cors {
configurationSource = corsConfigurationSource()
}
sessionManagement {
sessionCreationPolicy = SessionCreationPolicy.STATELESS
}
// 비인증 사용자 이동
exceptionHandling {
authenticationEntryPoint = LoginUrlAuthenticationEntryPoint("/login")
}
// 로그인 후 이동
formLogin {
defaultSuccessUrl("/", alwaysUse = true)
}
// 로그아웃
logout {
logoutUrl = "/logout"
logoutSuccessUrl = "/login"
deleteCookies("JSESSIONID")
invalidateHttpSession = true
}
// 보안상 중요하나, 테스트용으로는 일단 disable 처리
csrf { disable() }
// 인증 필터 작동 전에 JwtTokenFilter를 작동시킴
addFilterBefore<UsernamePasswordAuthenticationFilter>(JwtTokenFilter(JwtTokenUtil(), secretKey))
}
return http.build()
}
cors 부분
위 filterChain 빈 바로 밑에 추가로 작성해주었다.
@Bean
fun corsConfigurationSource(): CorsConfigurationSource {
val configuration = CorsConfiguration()
//CORS를 허용해줄 주소를 적는다. 여기에 배포할 서비스의 도메인 주소도 적어줘야한다.
configuration.allowedOrigins = listOf("http://localhost:3000", "http://localhost:8080", "/swagger-ui/**")
configuration.allowedMethods = listOf("GET", "POST", "PUT", "DELETE")
configuration.addAllowedHeader("*")
configuration.allowCredentials = true
val source = UrlBasedCorsConfigurationSource()
source.registerCorsConfiguration("/**", configuration)
return source
}
JwtTokenUtil
따로 클래스 파일로 빼주었다. createToken 메서드에서 loginId, role, authProvider 정보를 이용해서 토큰을 만들어서 반환한다. parsePayload에서는 JWT Token의 정보를 해체해서 loginId, role, authProvider 등의 정보를 얻을 수 있다.
@Component
class JwtTokenUtil {
fun createToken(
loginId: String,
role: UserRole,
authProvider: AuthProvider,
secretKey: String,
issueTimeMs: Long,
expireTimeMs: Long
): String {
val claims: Claims = Jwts.claims()
claims["loginId"] = loginId
claims["role"] = role
claims["authProvider"] = authProvider
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(Date(issueTimeMs))
.setExpiration(Date(expireTimeMs))
.signWith(Keys.hmacShaKeyFor(secretKey.toByteArray()), SignatureAlgorithm.HS256)
.compact()
}
fun getUserId(token: String, secretKey: String): String {
return parsePayload(token, secretKey)["loginId"].toString()
}
fun getRole(token: String, secretKey: String): Any? {
return parsePayload(token, secretKey)["role"]
}
fun getAuthProvider(token: String, secretKey: String): Any? {
return parsePayload(token, secretKey)["authProvider"]
}
private fun parsePayload(token: String, secretKey: String): Claims {
try {
return Jwts.parserBuilder()
.setSigningKey(secretKey.toByteArray())
.build()
.parseClaimsJws(token)
.body
} catch (e: io.jsonwebtoken.security.SignatureException) {
throw BaseException(BaseResponseCode.VALID_SECRET_KEY)
} catch (e: MalformedJwtException) {
throw BaseException(BaseResponseCode.VALID_TOKEN)
} catch (e: ExpiredJwtException) {
throw BaseException(BaseResponseCode.EXPIRED_TOKEN)
}
}
}
jwt.io 사이트에 가보면 생성되는 jwt 정보를 아래처럼 파싱할 수 있다. parsePayload 메서드는 그런 역할을 한다.
JwtTokenFilter
위 JwtTokenUtil과 함께 사용하여 SecurityConfig에서 JWT 토큰 처리를 한다. authorities에 role을 리스트로 추가할 수 있는데, 나의 경우 User마다 1개 role만 처리하였다.
class JwtTokenFilter(private val jwtTokenUtil: JwtTokenUtil, private val secretKey: String): OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION)
if (authorizationHeader == null) {
filterChain.doFilter(request, response)
return
}
val token = authorizationHeader.substring(7)
// JWT의 payload에서 userId와 role 가져옴
val userId = jwtTokenUtil.getUserId(token, secretKey)
val role = jwtTokenUtil.getRole(token, secretKey) as String
val authProvider = jwtTokenUtil.getAuthProvider(token, secretKey)
val authorities: MutableList<GrantedAuthority> = ArrayList()
// role을 authorities에 추가함
authorities.add(SimpleGrantedAuthority(role))
val authenticationToken = UsernamePasswordAuthenticationToken(
userId + authProvider,
null,
authorities
)
authenticationToken.details = WebAuthenticationDetailsSource().buildDetails(request)
// 스프링 시큐리티에 미리 구현된 필터에서 authentication.authorities를 검사를 수행함
SecurityContextHolder.getContext().authentication = authenticationToken;
filterChain.doFilter(request, response);
return
}
}
끝났다. 이제 보통 API를 만들때처럼 Controller와 Service를 구현하여 처리하면 된다. HTTP 요청이 올때마다 SecurityConfig에서 만들어놓은 filter를 거치면서 인증처리가 된다.
회원가입, 로그인 service logic은 아래처럼 구현하되, 필요한 내용들을 알아서 추가해주면 된다.
@Transactional
fun signup(userSignupRequest: UserSignupRequest): UserDto {
if (userRepository.existsByEmailAndAuthProvider(userSignupRequest.email, userSignupRequest.authProvider)) {
throw BaseException(BaseResponseCode.USER_ALREADY)
}
//password는 encoding해서 저장
userSignupRequest.password = passwordEncoder.encode(userSignupRequest.password)
val userEntity = userSignupRequest.toEntity()
val user = userRepository.save(userEntity)
return UserDto.fromUser(user)
}
fun login(email: String, authProvider: AuthProvider?, password: String): UserDto {
val actualAuthProvider = authProvider ?: AuthProvider.NONE
val user = userRepository.findByEmailAndAuthProvider(email, actualAuthProvider)
?: throw BaseException(BaseResponseCode.USER_NOT_FOUND)
//로그인 시 passwordEncoder.matches 메서드가 해싱되어 DB에 저장된 password와 일치여부를 검사한다.
if (!passwordEncoder.matches(password, user.password)) {
throw BaseException(BaseResponseCode.INVALID_PASSWORD)
}
return UserDto.fromUser(user)
}
추가. BaseException: @RestControllerAdvice
위 코드에서 에러가 발생한 경우 BaseException으로 예외처리를 했다. BaseException 또한 BaseFilter를 만들고 @RestControllerAdvice를 추가해주면 API단에서 발생한 에러들이 BaseFilter를 거치는 경우 작성한대로 에러 처리가 되는 구조이다. 따로 패키지 및 class를 빼서 작성했다.
ControllerAdvice와 RestControllerAdvice, ExceptionHandler에 대한 내용은 링크 참조
@RestControllerAdvice
class BaseFilter {
@ExceptionHandler(*[BaseException::class])
fun handle(ex: BaseException): ResponseEntity<BaseResponseError> {
val errorResponse = BaseResponseError(ex.status, ex.message)
return ResponseEntity.status(ex.status).body(errorResponse)
}
@ExceptionHandler(*[java.lang.NullPointerException::class, ClassCastException::class, java.lang.Exception::class])
fun handle(ex: Exception): ResponseEntity<String> {
println(ex.message)
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("요청 데이터가 올바르지 않습니다")
}
}
class BaseException(private val responseCode: BaseResponseCode): RuntimeException() {
val status: HttpStatus = responseCode.status
override val message: String = responseCode.message
}
enum class BaseResponseCode(val status: HttpStatus, val message: String) {
BAD_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."),
...
}
2. Swagger에서 로그인 처리
목표
Swagger에 Authorize를 달아서 로그인을 해야만 시큐리티에서 허용하는 API에 접근가능하도록 한다.
참조 페이지에 잘 나와있다.
https://wonsjung.tistory.com/584
코틀린이라 약간 다른 문법으로 작성했다.
@RequiredArgsConstructor
@Configuration
class SwaggerConfig: OpenAPI() {
@Value("\${spring.config.activate.on-profile}")
lateinit var activeProfile: String
@Bean
fun openAPI(): OpenAPI {
val info = Info()
info.title = "test"
info.description = "test api"
info.version = "v1"
//Server를 dropdown으로 고를 수도 있는데, 오히려 헷갈리는 것 같아서 환경별로 특정해주었다.
val server = Server()
if(activeProfile == "local" || activeProfile == "dev") {
server.url = "http://localhost:8080"
server.description = "LOCAL SERVER"
} else if(activeProfile == "prod") {
server.url = "{실제 서버용 주소}"
server.description = "PROD SERVER"
}
//JWT를 사용하는 방식이라면 이렇게 세팅
val securityScheme = SecurityScheme()
securityScheme.type = SecurityScheme.Type.HTTP
securityScheme.scheme = "bearer"
securityScheme.bearerFormat = "JWT"
securityScheme.name = "Authorization"
securityScheme.`in` = SecurityScheme.In.HEADER
val securityRequirement = SecurityRequirement()
securityRequirement.addList("bearerAuth")
val openAPI = OpenAPI()
openAPI.info = info
openAPI.servers = listOf(server)
openAPI.components = Components().addSecuritySchemes("bearerAuth", securityScheme)
openAPI.security = listOf(securityRequirement)
return openAPI
}
}
왼쪽 체크 부분이 Server이다. 호스트 주소를 선택할 수 있게 해주나, 코드상 주석처럼 환경별로 고정 처리했다.
Authorize 버튼을 누르면 나오는 창에
로그인 후 응답값으로 받아오는 JWT 값을 입력, Authorize 버튼을 누르고 API를 요청해보면 SecurityConfig에서 설정한대로 API를 처리할 수 있다.
참고
'Project > Poppin' 카테고리의 다른 글
mysql dump(docker instance) (1) | 2024.01.01 |
---|---|
코틀린 logback 환경 분리, validated 검증, exception 처리, CD 적용 (1) | 2024.01.01 |
S3 이미지 업로드 구현, Profile 설정 및 불러오기, Swagger 이미지 업로드(multipart) (0) | 2023.12.25 |
Thymeleaf로 input 확인 및 수정 Admin 페이지 만들기(jquery, script) (0) | 2023.12.17 |
AWS Credential(AcceesKey, SecretKey), github Actions - ECR CI 설정하기 (0) | 2023.12.09 |