[Kotlin]

[Kotlin + Spring Security + JWT] 회원가입 로그인 기능 구현 (2)

미냠 2023. 10. 15. 21:01
반응형

지난 포스팅으로는 Kotlin 으로 회원가입 기능을 구현해봤다.

https://miny-dev.tistory.com/4

이번 포스팅으로는 이론적인 내용의 로그인 기능을 실제 구현해보려고 한다.

먼저 Spring Security라는 Spring 기반의 애플리케이션 보안을 담당하는 하위 프레임위크를 사용했으며

세션 ID 토큰 생성으로는 JWT를 활용하였다.

JWT의 구조를 간단하게 설명하자면

HEADER.PAYLOAD.SIGNATURE

헤더.내용.서명

로 구성되어 있으며 완성된 토큰은 이런 형태를 갖게된다.

(예시)

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

이런 토큰들은 암호화되어있는 것으로 인코딩, 디코딩을 통해서 인증 절차를 밟게 된다.

로그인 기능을 구현하기 위해 먼저 build.grable.kts의 dependencies에 코드를 추가해주었다.

build.grable.kts

// https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-security

implementation("org.springframework.boot:spring-boot-starter-security")

// https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-api

implementation("io.jsonwebtoken:jjwt-api:0.11.5")

// https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-impl

runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")

// https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-jackson

runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5")

maven이나 다른 버전의 코드가 필요한 경우 주석 처리된 링크에서 확인 가능하다.

build.grable.kts 코드를 추가해주고 나면 꼭 `Reload All Gradle Projects`를 클릭해서 빌드를 해야한다.

그리고 application.properties 에 jwt secret key로 사용될 값을 지정해두었다.

이것은 임의로 지정한 값이다. 구글링하면 다양하게 나올 것이다.

jwt.secret = DadFufN4Oui8Bfv3ScFj6R9fyJ9hD45E6AGFsXgFsRhT4YSdSb

먼저 jwt 인증 타입과 실제 인증 token을 관리할 data class를 생성해준다.

TokenInfo

data class TokenInfo (

val grantType: String, // jwt 인증 권한 인증 타입

val accessToken: String, // 실제 인증할 token

)

그리고 토큰을 생성하고 생성된 토큰 정보를 반환하는 로직을 추가했다.

JwtTokenProvider.kt

const val EXPIRATION_MILLISECONDS: Long = 1000 * 60 * 30

// 토큰을 생성하고 토큰 정보를 추출

@Component

class JwtTokenProvider {

@Value("\${jwt.secret}")

lateinit var secretKey: String

private val key by lazy {Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey))}

/**

* Token 생성

*/

fun createToken (authentication: Authentication): TokenInfo {

// authentication 안에 권한 리스트를 , 로 나누어 string으로 저장

val authorities: String = authentication

.authorities

.joinToString(",", transform = GrantedAuthority::getAuthority)

val now = Date()

val accessExpiration = Date(now.time + EXPIRATION_MILLISECONDS)

// Access Token

val accessToken = Jwts

.builder()

.setSubject(authentication.name)

.claim("auth", authorities) // string 으로 저장한 권한을 auth에 저장

.claim("userId", (authentication.principal as CustomUser).userId) // userId 값을 추가하여 사용자 토큰 커스텀

.setIssuedAt(now) // 현재 발행시간

.setExpiration(accessExpiration) // 만료기간

.signWith(key, SignatureAlgorithm.HS256) // 키 사용 알고리즘

.compact()

// Bearer 에 위에서 생성한 accessToken 을 담아서 TokenInfo 로 반환

return TokenInfo("Bearer", accessToken)

}

/**

* Token 추출

*/

fun getAuthentication(token: String): Authentication {

// getClaims fun 에서 token으로 claims 조회

val claims: Claims = getClaims(token)

// claims auth가 존재하지 않으면 잘못된 토큰 (토큰 생성 부분에서 claims에 auth를 추가하는 부분이 있음)

val auth = claims["auth"] ?: throw RuntimeException("잘몬된 토큰입니다.")

val userId = claims["userId"] ?: throw RuntimeException("잘몬된 토큰입니다.")

// 권한 정보 추출

val authorities: Collection<GrantedAuthority> = (auth as String)

.split(",")

.map {SimpleGrantedAuthority(it)}

val principal: UserDetails = CustomUser(userId.toString().toLong(), claims.subject, "", authorities)

// 조회한 권한 정보로 UsernamePasswordAuthenticationToken 생성

return UsernamePasswordAuthenticationToken(principal, "", authorities)

}

/**

* Token 검증

*/

fun validateToken(token: String): Boolean {

try {

// Token으로 Claims 정보 조회

getClaims(token)

return true

} catch (e: Exception) {

when (e) {

is SecurityException -> {} // Invalid JWT Token

is MalformedJwtException -> {} // Invalid JWT Token

is ExpiredJwtException -> {} // Expired JWT Token

is UnsupportedJwtException -> {} // Unsupported JWT Token

is IllegalArgumentException -> {} // JWT claims string is empty

else -> {} // else

}

println(e.message)

}

return false

}

// claims 추출

private fun getClaims(token: String): Claims =

Jwts.parserBuilder()

.setSigningKey(key)

.build()

.parseClaimsJws(token)

.body

}

Token을 검사하는 로직으로 token의 유효성을 확인하고 일치하면 key값을 추출한다.

JwtAuthenticationFilter

// Token 검사

// GenericFilterBean 상속 -> JwtTokenProvider를 생성자로 받게 되어있음

class JWTAuthenticationFilter(private val jwtTokenProvider: JwtTokenProvider): GenericFilterBean() {

override fun doFilter(request: ServletRequest?, response: ServletResponse?, chain: FilterChain?) {

val token = resolveToken(request as HttpServletRequest)

// token 유효성 확인

if (token != null && jwtTokenProvider.validateToken(token)) {

val authentication = jwtTokenProvider.getAuthentication(token)

// SecurityContextHolder에 기록하고 사용

SecurityContextHolder.getContext().authentication = authentication

}

chain?.doFilter(request, response)

}

// request의 header에 Authorization 조회하여 Bearer와 맞는지 확인하고 맞으면 뒤의 key 값을 가져옴

private fun resolveToken(request: HttpServletRequest): String? {

val bearerToken = request.getHeader("Authorization")

return if(StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {

bearerToken.substring(7)

} else {

null

}

}

}

다음은 필터를 통해 접근 권한을 설정한다.

이것을 통해서 url 마다 접근 가능한 권한을 설정한다.

회원 가입과 로그인은 사용자 인증이 되지 않아야 접근 가능하며

it.requestMatchers("/api/member/signup", "/api/member/login").anonymous()

api/member로 시작하는 모든 요청은 MEMBER 권한이 있어야 접근 가능,

.requestMatchers("/api/member/**").hasRole("MEMBER")

그외 요청은 권한 없이 모두 접근 가능하다.

.anyRequest().permitAll()

SecurityConfig

 

@Configuration

@EnableWebSecurity

class SecurityConfig(private val jwtTokenProvider: JwtTokenProvider) {

// 필터 설정

@Bean

fun filterChain(http: HttpSecurity): SecurityFilterChain {

http

// httpBasic, csrf 사용안함 처리

.httpBasic { it.disable() }

.csrf{ it.disable() }

// jwt를 사용하기 때문에 session 사용안함 처리

.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }

// 권한 관리

.authorizeHttpRequests {

// 해당 url로 접근하는 사용자는 인증되지 않은 사용자 여아함

it.requestMatchers("/api/member/signup", "/api/member/login").anonymous()

// 그외 /api/member로 시작하는 모든 요청은 MEMBER 권한이 있어야 접근 가능

.requestMatchers("/api/member/**").hasRole("MEMBER")

// 그외 요청은 권한 없이 모두 접근 가능

.anyRequest().permitAll()

}

// JWTAuthenticationFilter가 UsernamePasswordAuthenticationFilter보다 먼저 실행

// 앞의 필터가 성공하면 뒤 필터는 시행 x

.addFilterBefore(

JWTAuthenticationFilter(jwtTokenProvider),

UsernamePasswordAuthenticationFilter::class.java

)

return http.build()

}

// 비밀번호 암호화

@Bean

fun passwordEncoder(): PasswordEncoder =

PasswordEncoderFactories.createDelegatingPasswordEncoder()

}

enum class를 사용하여 회원 타입과 권한에 대한 코드를 지정해주었다.

Enumstatus.kt

// enum type Gender

enum class Gender(val desc: String ) {

MAN("남"),

WOMAN("여")

}

// enum type UserType

enum class UserType(val desc: String ) {

ADMIN("관리자"),

MEMBER("회원")

}

// return result code 생성

enum class ResultCode(val msg: String) {

SUCCESS("정상 처리 되었습니다."),

ERROR("에러가 발생했습니다.")

}

// 회원 권한

enum class ROLE {

MEMBER

}

이렇게 생성하면 토큰 정보가 누구나 동일하기 때문에 누구나 다른 사용자 아이디로 로그인이 가능하다

그래서 token에 userId를 추가해서 인코딩함으로서 본인외에 다른 사용자가 접근이 불가하도록 했다.

먼저 CustomUser DTO를 생성했다.

CustomUser

// Token에 userId를 저장하여 본인 외에 다른 아이디 접속 방지

class CustomUser(

val userId: Long,

userName: String,

password: String,

authorities: Collection<GrantedAuthority>

) : User(userName, password, authorities)

로그인 시, 사용자의 존재 여부를 확인하고 존재하는 경우 CustomUser class로 반환하는 로직이다.

CustomUserDetailsService

@Service

class CustomUserDetailsService(

private val userRepository: UserRepository,

private val passwordEncoder: PasswordEncoder,

) : UserDetailsService{

override fun loadUserByUsername(username: String): UserDetails =

userRepository.findByEmail(username)

// 존재하지 않으면 exception 발생

?.let {createUserDetails(it) } ?: throw UsernameNotFoundException("해당 유저는 없습니다.")

// user 인스턴스를 userdetail로 반환

private fun createUserDetails(user: User): UserDetails =

CustomUser(

user.id!!,

user.email,

passwordEncoder.encode(user.password),

user.userRole!!.map { SimpleGrantedAuthority("ROLE_${it.role}") }

)

}

사용자 관련 dto는 requset 때 사용하는 dto와 response용 dto,그리고 로그인 시의 dto로 구분하였다.

UserDtos.kt

data class UserDtoRequest (

var id: Long?,

// dto 로 받는 값들에 validation 추가

// 빈값 입력 불가

@field:NotBlank

@field:Email

// email 와 _email 연결

@JsonProperty("email")

private val _email: String?,

@field:NotBlank

// 입력 패턴 정규식

@field:Pattern(

regexp="^(?=.*[a-zA-Z])(?=.*[0-9])(?=.*[!@#\$%^&*])[a-zA-Z0-9!@#\$%^&*]{8,20}\$",

message = "영문, 숫자, 특수문자를 포함한 8~20자리로 입력해주세요"

)

@JsonProperty("password")

private val _password: String?,

@field:NotBlank

@JsonProperty("name")

private val _name: String?,

@field:NotBlank

// ValidEnum으로 생성한 annotation 호출

@field:ValidEnum(enumClass = UserType::class,

message = "ADMIN 이나 MEMBER 중 하나를 선택해주세요")

@JsonProperty("userType")

private val _userType: String?,

@JsonProperty("point")

private val _point: Long?,

) {

val email: String

// _loginId 는 null 값도 허용 되는 타입이지만 loginId는 null을 허용하지 않는 변수로 선언했기 때문에 !! 를 붙여줘야함

// !! : null 값이 들어갈 수있는 변수 이지만 null 값이 아님을 보장

get() = _email!!

val password: String

get() = _password!!

val name: String

get() = _name!!

// UserType enum class

val userType: UserType

get() = UserType.valueOf(_userType!!)

val point: Long

get() = _point!!

// string 에 .toLocalDate()를 입력하면 LocalDate로 반환하는 확장 함수 생성

private fun String.toLocalDate(): LocalDate =

// LocalDate의 format 형식 추가

LocalDate.parse(this, DateTimeFormatter.ofPattern("yyy-MM-dd"))

// entity로 변환해서 반환하는 함수 생성

fun toEntity(): User =

User(id, email, password, name, userType, point)

}

// 로그인 DTO

data class LoginDto(

@field:NotBlank

@JsonProperty("email")

private val _email: String?,

@field:NotBlank

@JsonProperty("password")

private val _password: String?,

) {

val email: String

get() = _email!!

val password: String

get() = _password!!

}

data class UserDtoResponse (

val id: Long,

val email: String,

val name: String,

val userType: String,

val point: Long,

)

UserEntitiies.kt 에는 기본적으로 사용자에 관련된 클래스와 권한에 대한 역할 정보를 저장하는 클래스를 추가했다.

UserEntitiies.kt

// Client로 부터 받은 DTO 정보를 DB에 저장하기 위한 Entity 생성

@Entity

@Table(

// email 중복 불허를 위해 unique column 으로 지정

uniqueConstraints = [UniqueConstraint(name = "uk_user_email", columnNames = ["email"])]

)

class User(

@Id

@GeneratedValue(strategy = GenerationType.AUTO)

var id: Long? = null,

@Column(nullable = false, length = 30, updatable = false)

val email: String,

@Column(nullable = false, length = 100)

val password: String,

@Column(nullable = false, length = 10)

val name: String,

@Column(nullable = false, length = 5)

@Enumerated(EnumType.STRING) // enum class code insert

val userType: UserType,

@Column(nullable = false)

val point: Long,

): BaseEntity() {

// 회원 권한 저장

@OneToMany(fetch = FetchType.LAZY, mappedBy = "user")

val userRole: List<UserRole>? = null

// LocalDate 타입 string 변환 확장 함수

private fun LocalDate.formatDate(): String =

this.format(DateTimeFormatter.ofPattern("yyyyMMdd"))

// 회원 정보 변경

// birthDate 은 LocalDate 타입으로 string 으로 변경 포멧 함수 필요

fun toDto(): UserDtoResponse =

UserDtoResponse(id!!, email, name, userType.desc, point)

}

// enum class로 생성한 회원 권한 Entity

@Entity

class UserRole(

@Id

@GeneratedValue(strategy = GenerationType.AUTO)

var id: Long? = null,

@Column(nullable = false, length = 30)

@Enumerated(EnumType.STRING)

val role: ROLE,

@ManyToOne(fetch = FetchType.LAZY)

@JoinColumn(foreignKey = ForeignKey(name = "fk_user_role_user_id"))

val user: User

)

 

UserRepository.kt 는 email로 회원정보 조회하는 함수

그리고 회원가입 시 권한 정보를 저장하는 함수를 추가했다.

UserRepository.k

interface UserRepository : JpaRepository<User, Long> {

// email 로 회원정보 조회

fun findByEmail(email: String): User?

}

// 회원가입 시 권한 정보 저장

interface UserRoleRepository : JpaRepository<UserRole, Long>

UserService에는 로그인 시 아이디와 비밀번호로 토큰을 발행하고 해당 토큰의 이상 여부를 확인하여 반환한다.

이렇게 로그인 시에 발행된 토큰을 사용하여 다른 url 접근 시 headr에 넣어 요청하면 로그인 상태가 유지되는 것이다.

추가로 회원정보 조회와 수정로직도 추가했다.

UserService

@Transactional

@Service

class UserService(

private val userRepository: UserRepository,

private val userRoleRepository: UserRoleRepository,

private val authenticationManagerBuilder: AuthenticationManagerBuilder,

private val jwtTokenProvider: JwtTokenProvider

) {

/*

* 회원가입

* */

fun signUp(userDtoRequest: UserDtoRequest): String {

// ID 중복 검사

var user: User? = userRepository.findByEmail(userDtoRequest.email)

if (user != null) {

// return "이미 등록된 ID 입니다."

// Exception 으로 발생하여 처리

throw InvalidInputException("email", "이미 등록된 EMAIL 입니다.")

}

// user dto를 entity 함수로 변환

user = userDtoRequest.toEntity()

userRepository.save(user)

// 회원 권한 저장

val userRole: UserRole = UserRole(null, ROLE.MEMBER, user)

userRoleRepository.save(userRole)

return "회원가입이 완료되었습니다."

}

/**

* 로그인 -> 토큰 발행

*/

fun login(loginDto: LoginDto): TokenInfo {

// 아이디와 비밀번호로 UsernamePasswordAuthenticationToken 발행

val authenticationToken = UsernamePasswordAuthenticationToken(loginDto.email, loginDto.password)

// 생성된 토큰을 authenticationManagerBuilder로 전달, db user 정보와 비교

val authentication = authenticationManagerBuilder.`object`.authenticate(authenticationToken)

// 이상이 없는 경우 토큰 발행 후 반환

return jwtTokenProvider.createToken(authentication)

}

/**

* 회원 정보 조회

*/

fun searchMyInfo(id: Long): UserDtoResponse {

val user: User = userRepository.findByIdOrNull(id) ?: throw InvalidInputException("id", "회원번호(${id})가 존재하지 않는 유저입니다.")

return user.toDto()

}

/**

* 회원 정보 수정

*/

fun saveMyInfo(userDtoRequest: UserDtoRequest): String {

val user: User = userDtoRequest.toEntity()

userRepository.save(user)

return "수정 완료되었습니다."

}

}

이렇게 로그인과 회원정보 수정에 대한 코드 작성이 끝이났고 마지막으로 controller에 대한 코드다.

로그인, 회원정보 조회, 회원정보 수정 이렇게 추가했으며

로그인이 완료되어야지만 회원정보 조회, 수정이 가능하게 된다.

UserController

@RequestMapping("/api/user")

@RestController

class UserController(private val userService: UserService) {

/*

* 회원가입

* */

@PostMapping("/signup")

// dto 유효성 검사 추가

// exception 반환을 위해 string -> baseresponse로 return

fun signUp(@RequestBody @Valid userDtoRequest: UserDtoRequest): BaseResponse<Unit> {

val resultMsg: String = userService.signUp(userDtoRequest)

return BaseResponse(message = resultMsg)

}

/*

* 로그인

* */

@PostMapping("/login")

fun login(@RequestBody @Valid loginDto: LoginDto): BaseResponse<TokenInfo> {

val tokenInfo = userService.login(loginDto)

// 토큰 반환

return BaseResponse(data = tokenInfo)

}

/*

* 회원 정보 보기

* */

@GetMapping("/info")

fun searchMyInfo(): BaseResponse<UserDtoResponse> {

// email를 url에서 아닌 SecurityContextHolder 에서 조회

val userId = (SecurityContextHolder.getContext().authentication.principal as CustomUser).userId

val response = userService.searchMyInfo(userId)

return BaseResponse(data = response)

}

/*

* 회원 정보 수정

* */

@PutMapping("/info")

fun saveMyInfo(@RequestBody @Valid userDtoRequest: UserDtoRequest): BaseResponse<UserDtoResponse> {

val userId = (SecurityContextHolder.getContext().authentication.principal as CustomUser).userId

userDtoRequest.id = userId

val resultMsg: String = userService.saveMyInfo(userDtoRequest)

return BaseResponse(message = resultMsg)

}

}

이렇게 코드는 다 추가했고 Postman으로 테스트를 해보겠다.

먼저 회원가입이다.

userType은 MEMBER로 저장해보겠다.

response에서 성공적으로 회원가입이 완료되었다는 것을 확인했다.

그리고 DB에도 추가된 것을 확인할 수 있다.

그리고 회원 역할에 대한 데이터도 확인이 가능하다.

이제 회원가입한 이메일과 비밀번호로 로그인을 해보겠다.

성공적으로 로그인이 되었으며 response 값으로 accessToken 값을 반환해주었다.

이후 호출은 이 토큰값을 추가해줘야 로그인 상태를 유지할 것이다.

사용자 정보를 조회해보도록 하겠다.

이때 위에서 반환된 Token 값을 header autiorization에 추가해주어야한다.

토큰 값을 추가할 때는 Bearer 뒤에 토큰 값을 추가해야한다.

이렇게 전송된 토큰 값이 정상적인 토큰으로 확인되는 경우 처음 회원가입한 사용자 정보를 리턴해준다.

만약 토큰값이 틀리면 어떻게 될까?

오류가 발생하게 된다.

이때 미처리 에러가 아닌 정확한 exception을 설정해주는게 좋을 것같다

(추후에 수정하는 것으로,,)

그리고 마지막으로 회원정보를 수정해보겠다.

회원정보 수정 역시 로그인 토큰이 있어야 접근이 가능하기 때문에

위와 동일하게 header의 autiorization에 Bearer와 토큰값이 있어야한다.

이렇게 성공적으로 회원정보 수정까지 완료된 것을 확인할 수 있다.

이렇게 Kotlin + Spring Security + JWT을 이용한 회원가입, 로그인 기능을 구현해보았다.

위의 내용은 inflearn의 강의를 통해서 학습한 내용을 정리한 것으로

정리한 내용이 이해가지 않는 다면 해당 강의를 들을 것을 추천한다.

강사님 목소리가 작아서 굉장히 집중력을 요하지만 너무 잘 설명해주셔서 추천추천

https://www.inflearn.com/course/%EC%BD%94%ED%8B%80%EB%A6%B0%EA%B3%BC-spring-security-jwt-%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%85%EB%A7%8C%EB%93%A4%EA%B8%B0

반응형