꿈틀꿈틀 개발일기

Spring | 로그인 구현하기

by jeongminy

1. 로그인의 구현 방법

로그인을 구현하는 방법은 두가지가 있다.

 

1. Spring Security의 Filter를 사용하여 구현 하는 방법

먼저 Spring Security의 Filter를 사용하는 방법은 모든 인증을 Spring Security를 통해 처리하는 방식입니다. 

Spring Security Filter는 요청을 가로채서 인증과 권한 부여를 처리하고, 로그인과 로그아웃을 관리합니다. 

이 방법을 선택하면 Spring Security가 제공하는 다양한 인증 및 권한 관련 기능을 활용할 수 있고, 보안에 대한 신뢰도 또한 높아집니다.

//Spring Security의 Filter를 이용한 방식의 예시
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
//UsernamePasswordAuthenticationFilter를 상속 받아서 사용할 수 있음.
class EmailPasswordAuthenticationFilter: UsernamePasswordAuthenticationFilter {
}

 

2. Controller와 Service를 사용하여 구현 하는 방법 (우리가 선택한 방법)
반면에 Controller와 Service를 사용하여 구현하는 방법은 직접 로그인과 관련된 비즈니스 로직을 작성하는 방식입니다. 

이 방법을 선택하면 개발자가 직접 로그인 처리를 구현하고, 필요한 경우에만 Spring Security의 기능을 추가적으로 활용할 수 있습니다. 예를 들어, 특정 요청에 대해서만 인증을 처리하고 싶거나, 커스텀한 로그인 로직을 구현하고 싶을 때 유용합니다.

따라서, 모든 인증을 Spring Security를 통해 처리하고 싶다면 Filter를 사용하고, 그렇지 않다면 Controller를 사용하여 구현하면 됩니다. 이렇게 선택하는 것은 개발자의 요구사항과 프로젝트의 복잡성에 따라 다를 수 있습니다.

 

 

2. 세션인증기반과 토큰인증기반

  • 세션(Session)
    세션은 서버 측에서 관리하는 로그인 정보입니다. 사용자가 웹 사이트에 로그인하면, 서버는 이러한 정보를 세션에 저장하고, 세션 ID를 생성합니다. 그 후, 이 세션 ID는 클라이언트 측에 전달되어, 클라이언트는 이 세션 ID를 쿠키라는 곳에 저장합니다. 이후 클라이언트가 다시 서버에 요청을 보낼 때마다 이 세션 ID를 함께 보내게 되어, 서버는 이 세션 ID를 확인하여 해당 사용자의 로그인 정보를 알 수 있습니다. 이렇게 서버가 사용자의 로그인 정보를 계속해서 기억해야 하는 것을 '상태 유지(Stateful)'라고 합니다.

  • 토큰(Token) (우리가 선택한 방법)
    토큰은 클라이언트 측에서 관리하는 로그인 정보입니다. 사용자가 로그인하면 서버는 인증 정보를 포함한 토큰을 생성하여 클라이언트에게 전달합니다. 클라이언트는 이 토큰을 저장한 후, 서버에 요청을 보낼 때마다 이 토큰을 함께 보내게 됩니다. 서버는 매번 토큰을 확인하여 인증을 진행하므로, 서버는 사용자의 상태를 기억할 필요가 없습니다. 이러한 방식을 '무상태(Stateless)'라고 합니다.

  • 간단한 비유로 설명드리면, 세션은 학교의 출석체크 시스템과 같습니다. 학생이 등교하면 선생님이 출석부에 출석을 체크하고, 매시간마다 학생들의 출석 상태를 기억하며 관리합니다. 반면에 토큰은 영화관의 입장권과 같습니다. 입장권을 구매하면 관람객은 그 입장권을 지니고 있어야 하며, 매번 입장할 때마다 입장권을 보여야 합니다. 이때 영화관은 관람객의 상태를 계속 기억할 필요가 없습니다.

  • 이렇게 보면, 세션과 토큰은 각각의 장단점이 있습니다. 세션은 서버가 모든 정보를 관리하므로 보안성이 높지만, 사용자가 많아질수록 서버의 부하가 증가하는 단점이 있습니다. 반면에 토큰은 서버의 부하를 줄일 수 있지만, 토큰 자체가 탈취당하면 보안 문제가 발생할 수 있습니다. 따라서 상황에 따라 적절한 방식을 선택하는 것이 중요합니다.

 

3. PasswordEncoderConfig 작성

package com.teamsparta.courseregistration.infra.security

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder

@Configuration
class PasswordEncoderConfig {
    //PasswordEncoderConfig 클래스는 Spring의 설정 클래스로서,
    //BCryptPasswordEncoder를 사용하여 비밀번호를 암호화하기 위한 PasswordEncoder 빈을 생성하고 등록하는 역할을 합니다.
    //이를 통해 애플리케이션에서 안전하게 비밀번호를 다룰 수 있습니다.

    @Bean
    fun passwordEncoder(): PasswordEncoder {
        return BCryptPasswordEncoder()
    //BCryptPasswordEncoder()를 사용하여 PasswordEncoder를 생성하고 반환
    //BCryptPasswordEncoder는 Spring Security에서 제공하는 비밀번호 암호화 방식 중 하나로, 강력한 암호화를 제공
    //PasswordEncoder는 사용자의 비밀번호를 안전하게 저장하기 위해 사용되며, 입력된 비밀번호를 암호화하거나, 
    //저장된 암호화된 비밀번호와 일치하는지 검증하는 등의 기능을 수행
    }
}

PasswordEncoderConfig의 사용방법은 

UserServiceImpl에 가서 

private val passwordEncoder: PasswordEncoder

생성자 주입을 하고,

//위 코드 생략

@Transactional
override fun signUp(request: SignUpRequest): UserResponse {
    if (userRepository.existsByEmail(request.email)) {
        throw IllegalStateException("Email is already in use")
    }

    return userRepository.save(
        User(
            email = request.email,
            password = passwordEncoder.encode(request.password),//비밀번호 암호화
            profile = Profile(
            nickname = request.nickname
            ),
            role = when (request.role) {
                UserRole.STUDENT.name -> UserRole.STUDENT
                UserRole.TUTOR.name -> UserRole.TUTOR
                else -> throw IllegalArgumentException("Invalid role")
            }
        )
    ).toResponse()
}

//아래 코드 생략

request.password 였던 부분을 password = passwordEncoder.encode(request.password) 로 변경해 줄 수 있다.

따라서, DB에 password를 저장할 때 암호화 되어서 저장하게 된다.

 

 

4. Controller 작성

@PostMapping("/login")
    fun login(@RequestBody loginRequest: LoginRequest): ResponseEntity<LoginResponse> {
        return ResponseEntity
            .status(HttpStatus.OK)
            .body(userService.login(loginRequest))
    }

 

 

 

5. LoginRequest 작성

package com.teamsparta.courseregistration.domain.user.dto

data class LoginRequest(
    val email: String,
    val password: String,
    val role: String,
)

 

 

 

6. LoginResponse 작성

class LoginResponse (
    val accessToken: String
)

 

 

7. Service 작성

login 메소드를 추가해주고,

package com.teamsparta.courseregistration.domain.user.service

import com.teamsparta.courseregistration.domain.user.dto.*

interface UserService {

    //위 코드 생략
   
    fun login(request: LoginRequest): LoginResponse //추가
    
    //아래 코드 생략
}

 

service 구현부에 가서 login로직을 작성해준다.

//위 코드 생략

@Service
class UserServiceImpl(
    private val userRepository: UserRepository,
    private val passwordEncoder: PasswordEncoder,
    private val jwtPlugin: JwtPlugin //주입
): UserService {

    override fun login(request: LoginRequest): LoginResponse {
        val user = userRepository.findByEmail(request.email) ?: throw ModelNotFoundException("User", null) //확인사항1: 이메일이 DB에 있는지 확인한다.

        if (user.role.name != request.role || !passwordEncoder.matches(request.password, user.password)) {
            //확인사항2: 역할이 일치하는지 확인
            //확인사항3: 비밀번호가 일치하는지 확인
            //passwordEncoder.matches() 메서드는 평문 비밀번호와 암호화된 비밀번호가 일치하는지를 확인하는 기능을 수행한다.
            throw InvalidCredentialException() //역할과 비밀번호가 일치하지 않을 시, InvalidCredentialException 예외 발생
        }
	//토큰을 생성하고 반환한다.
        return LoginResponse(
            accessToken = jwtPlugin.generateAccessToken(
                subject = user.id.toString(),
                email = user.email,
                role = user.role.name
            )
        )
    }
    
//이하 코드 생략
}

 

User Repository에 findByEmail 메소드를 추가해준다.

interface UserRepository: JpaRepository<User, Long> {

    fun findByEmail(email: String) : User? //추가

}

 

InvalidCredentialException 생성 (역할과 비밀번호가 맞지 않을 시 발생할 수 있는 예외)

package com.teamsparta.courseregistration.domain.user.exception

data class InvalidCredentialException(
    override val message: String? = "The credential is invalid"
): RuntimeException()

 

 

여기서 만약 Handler를 작성해 주지 않으면 상태코드 500이 반환 되는데, 

작성해주면 401을 반환해 줄 수 있다.

GlobalExceptionHandler에 handleInvalidCredentialException 추가

    @ExceptionHandler(InvalidCredentialException::class)
    fun handleInvalidCredentialException(e: InvalidCredentialException): ResponseEntity<ErrorResponse>{
        return ResponseEntity
            .status(HttpStatus.UNAUTHORIZED)
            .body(ErrorResponse(e.message))
    }

 

 

 

 

 

 

 

그밖에 코드 설명 :)

passwordEncoder.matches(요청한비번, DB에저장되어있는인코딩된비번)

passwordEncoder.matches() 메서드는 Spring Security에서 제공하는 PasswordEncoder 인터페이스의 메서드 중 하나입니다. 이 메서드는 주어진 평문 비밀번호와 암호화된 비밀번호가 일치하는지를 확인하는 기능을 수행합니다.

메서드 시그니처는 다음과 같습니다:

boolean matches(CharSequence rawPassword, String encodedPassword)
  • rawPassword: 비교하고자 하는 평문 비밀번호입니다.
  • encodedPassword: 암호화된 비밀번호입니다.

matches() 메서드는 rawPassword를 주어진 encodedPassword와 비교하여

일치하면 true를 반환하고,

일치하지 않으면 false를 반환합니다.

//예시
String rawPassword = "123456";
String encodedPassword = "$2a$10$7zHl6J0vWbQHd5x1eU4oaeEj2G8YD4V8GmW6TqQXg7C1xq9D7M3Ku";

boolean isMatch = passwordEncoder.matches(rawPassword, encodedPassword);
System.out.println(isMatch); // 출력: true


위의 예시에서 rawPassword는 "123456"이고, encodedPassword는 BCrypt 알고리즘을 사용하여 암호화된 비밀번호입니다. matches() 메서드를 사용하여 두 비밀번호를 비교하면, 일치하므로 true가 반환됩니다.

 

 

 

블로그의 정보

꿈틀꿈틀 개발일기

jeongminy

활동하기