지금까지 정수원님의 Spring Security 강의를 보며 Spring Security 포스팅을 작성해 왔다.
하지만 해당 강의는 5.x 버전 대의 내용을 다루고 있었고, Spring Boot 3(Framework 6.x)가 출시되면서 Spring Security 역시 6.x 버전이 출시가 되었고, 5.x 버전과는 변경점이 많아졌다고 생각한다. 따라서 6.x 버전을 기준으로 실전에서 요구될법 한 사항들을 구현해볼 것이다.
목표
1. API를 통해 이메일과 비밀번호로 인증
2. 토큰으로 클라이언트 인가
3. 리소스별 동적으로 권한 설정
4. 메서드별 권한 설정
간단하게 회원 가입 API를 만들고, API 기반으로 이메일과 비밀번호를 통해 로그인하도록 구현해볼 것이다. 이후 세션 기반으로 회원의 인증 정보를 관리하지 않고, 토큰 기반으로 인증 정보를 관리하도록 변경할 것이다. 그 다음에는 설정을 통해 정적으로 리소스별 인가 정책을 동적으로 변경할 수 있도록 수정할 것이다. 그 다음에는 메서드별로 권한을 검사하여 해당 회원이 메서드를 호출할 권한이 있는 지 뿐만 아니라, 영향을 받는 특정 리소스에 대해서도 권한이 있는지 체크해 볼 것이다.
예제 프로젝트 요구사항
네이버의 지식IN 처럼 질의 응답 게시판 서비스를 간단하게 만들 것이다. 한번 요구사항을 정리해보자.
1. 이메일, 비밀번호로 로그인이 가능하며, 가입 시 이름이 필요하다.
2. 회원의 역할은 일반, 전문가, 어드민 세 가지로 구성되어 있다.
3. 모든 회원은 기본적으로 일반 회원이다.
4. 전문가 역할은 가입한지 7일 이후부터 어드민 계정을 통해 가입할 수 있다.
5. 어드민 역할은 이메일이 특정 형식에 만족한다면 가입할 수 있다.
6. 모든 회원은 질문을 등록할 수 있다.
7. 일반 회원만 답변을 등록하지 못한다.
8. 게시글을 수정 및 삭제할 수 있으며, 이는 게시글의 작성자만 가능하다. 단, 어드민은 모든 글을 수정 및 삭제할 수 있다.
예제 프로젝트 기본 구현
인증 및 권한과 관련된 요구사항을 제외하고, 이외 필요한 기능들을 한번 구현해보자.
도메인 및 서비스
@Entity
class Account private constructor(
val name: String,
val password: String,
val email: String,
@Enumerated(STRING)
var role: AccountRole = AccountRole.NORMAL,
) {
@Id
@GeneratedValue(strategy = UUID)
@Column(name = "account_id", insertable = false, updatable = false)
val id: UUID = UUID.randomUUID()
val joinDateTime: LocalDateTime = LocalDateTime.now()
fun upgradeExpert() {
val requiredJoinPeriodInDays = AccountRole.EXPERT.upgradeEligibilityPeriodInDays
if (!isJoinDateBefore(requiredJoinPeriodInDays))
throw RoleUpdateNotAllowedException(
"전문가 계정은 가입한 지 ${requiredJoinPeriodInDays}일이 지난 계정만 업데이트 가능합니다."
)
this.role = AccountRole.EXPERT
}
private fun isJoinDateBefore(daysAgo: Long): Boolean {
val joinDate = this.joinDateTime.toLocalDate()
val dateToValidate = LocalDate.now().minusDays(daysAgo)
return joinDate.isBefore(dateToValidate) || joinDate.isEqual(dateToValidate)
}
companion object {
fun createNewAccount(
email: String,
name: String,
encodedPassword: String,
): Account {
return Account(
name = name,
password = encodedPassword,
email = email,
role = getRoleFromEmail(email)
)
}
private fun getRoleFromEmail(email: String): AccountRole {
val emailId = email.substringBeforeLast("@")
val emailDomain = email.substringAfterLast("@")
return if (emailId.endsWith(".admin") && emailDomain == "security.com")
AccountRole.ADMIN
else AccountRole.NORMAL
}
}
}
enum class AccountRole(
val upgradeEligibilityPeriodInDays: Long
) {
NORMAL(0),
EXPERT(7),
ADMIN(0),
}
계정은 이름, 이메일, 비밀번호, 역할을 가진다.
ADMIN 계정은 가입 시 이메일 형식에 따라서 부여되고, EXPERT 계정은 가입 7일 이후의 계정만 업그레이드 가능하도록 설정해놓았다.
@Entity
@Inheritance(strategy = SINGLE_TABLE)
@DiscriminatorColumn(name = "post_type")
abstract class Post(
var content: String,
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "writer_id")
val writer: Account,
) {
@Id @GeneratedValue(strategy = IDENTITY)
@Column(name = "post_id", insertable = false, updatable = false)
val id: Long = 0
@Column(name = "post_type", insertable = false, updatable = false)
@Enumerated
val type: PostType = PostType.QUESTION
val postDateTime: LocalDateTime = LocalDateTime.now()
var lastUpdateDateTime: LocalDateTime = LocalDateTime.now()
fun updateContent(updatedContent: String) {
this.content = updatedContent
this.lastUpdateDateTime = LocalDateTime.now()
}
}
@Entity
@DiscriminatorValue("QUESTION")
class Question private constructor(
content: String,
writer: Account,
) : Post(
content = content,
writer = writer,
) {
companion object {
fun createNewQuestion(
content: String,
writer: Account,
): Question {
return Question(
content = content,
writer = writer
)
}
}
}
@Entity
@DiscriminatorValue("ANSWER")
class Answer private constructor(
content: String,
expert: Account,
) : Post(
content = content,
writer = expert,
) {
companion object {
fun createNewAnswer(
content: String,
expert: Account,
): Answer {
return Answer(
content = content,
expert = expert,
)
}
}
}
질문과 답변은 글이라는 공통된 특징을 가지기에 상속관계로 구현했으며, 글을 수정하는 기능이 필요하기에 구현해 놓았다.
@Service
@Transactional
class AccountService(
private val passwordEncoder: PasswordEncoder,
private val accountRepository: AccountRepository,
) {
fun joinAccount(
email: String,
rawPassword: String,
name: String,
): UUID {
val newAccount = Account.createNewAccount(
email = email,
encodedPassword = passwordEncoder.encode(rawPassword),
name = name,
)
val savedAccount = accountRepository.save(newAccount)
return savedAccount.id
}
fun upgradeToExpert(
accountIdToUpgrade: String,
) {
val accountToUpgrade = accountRepository.findByIdOrThrow(
UUID.fromString(accountIdToUpgrade)
)
accountToUpgrade.upgradeExpert()
}
}
회원 가입 및 전문가 계정으로 업데이트 할 때의 비즈니스 로직을 구현했다.
@Service
@Transactional
class PostService(
private val accountRepository: AccountRepository,
private val postRepository: PostRepository,
) {
fun registerQuestion(
accountId: UUID,
content: String,
): Long {
val writer = accountRepository.findByIdOrThrow(accountId)
val newQuestion = Question.createNewQuestion(
content = content,
writer = writer,
)
val savedQuestion = postRepository.save(newQuestion)
return savedQuestion.id
}
fun registerAnswer(
accountId: UUID,
content: String,
): Long {
val writer = accountRepository.findByIdOrThrow(accountId)
val newQuestion = Answer.createNewAnswer(
content = content,
expert = writer,
)
val savedQuestion = postRepository.save(newQuestion)
return savedQuestion.id
}
fun updatePost(
postId: Long,
updatedContent: String,
) {
val postToUpdate = postRepository.findByIdOrThrow(postId)
postToUpdate.updateContent(updatedContent)
}
fun deletePost(
postId: Long,
) {
val postToDelete = postRepository.findByIdOrThrow(postId)
postRepository.delete(postToDelete)
}
}
글 생성, 수정, 삭제 비즈니스 로직을 구현했다.
웹
@RestController
@RequestMapping("/accounts")
class AccountController(
private val accountService: AccountService,
) {
@PostMapping("/join")
fun joinAccount(
@RequestBody request: AccountJoinRequest,
): ApiResponse<UUID> {
val savedId = accountService.joinAccount(
email = request.email,
rawPassword = request.password,
name = request.name,
)
return ApiResponse.ofSuccess(savedId)
}
@PostMapping("/upgrade-expert")
fun upgradeToExpert(
@RequestBody request: AccountRoleUpgradeRequest,
): ApiResponse<Any> {
accountService.upgradeToExpert(request.targetAccountId)
return ApiResponse.ofSuccess()
}
}
@RestController
@RequestMapping("/posts")
class PostController(
private val postService: PostService,
) {
@PostMapping("/questions")
fun registerQuestion(
@RequestBody request: PostRegisterRequest,
@AccountId accountId: UUID,
): ApiResponse<Long> {
val savedId = postService.registerQuestion(
accountId = accountId,
content = request.content
)
return ApiResponse.ofSuccess(savedId)
}
@PostMapping("/answers")
fun registerAnswer(
@RequestBody request: PostRegisterRequest,
@AccountId accountId: UUID,
): ApiResponse<Long> {
val savedId = postService.registerAnswer(
accountId = accountId,
content = request.content
)
return ApiResponse.ofSuccess(savedId)
}
@PatchMapping("/{postId}")
fun updatePost(
@PathVariable postId: Long,
@RequestBody request: PostUpdateRequest,
): ApiResponse<Any> {
postService.updatePost(
postId = postId,
updatedContent = request.content
)
return ApiResponse.ofSuccess()
}
@DeleteMapping("/{postId}")
fun deleteQuestion(
@PathVariable postId: Long,
): ApiResponse<Any> {
postService.deletePost(
postId = postId,
)
return ApiResponse.ofSuccess()
}
}
클라이언트에서 호출할 수 있도록 API를 구현했다.
@Component
class AccountIdArgumentResolver : HandlerMethodArgumentResolver {
override fun supportsParameter(parameter: MethodParameter): Boolean {
val hasAnnotation = parameter.hasParameterAnnotation(AccountId::class.java)
val isAssignableFrom = UUID::class.java.isAssignableFrom(parameter.parameterType)
return hasAnnotation && isAssignableFrom
}
override fun resolveArgument(
parameter: MethodParameter,
mavContainer: ModelAndViewContainer?,
webRequest: NativeWebRequest,
binderFactory: WebDataBinderFactory?
): Any {
val principal = SecurityContextHolder.getContext().authentication.principal
if (principal is UUID) return principal
else throw UnsupportedPrincipalException()
}
}
회원의 ID가 API에 따라 필요한 경우가 있는데, 이럴 경우에 사용하기 위해 SecurityContext에서 인증 객체의 정보를 가지고 회원의 ID를 추출한 다음, 핸들러의 파라미터에 제공하도록 ArgumentResolver를 커스터마이징 했다.
이번 포스팅에서 다룬 전체 소스코드는 깃허브를 참고해주세요.