Skip to content

Commit

Permalink
Merge pull request #78 from jxmen/feature/login
Browse files Browse the repository at this point in the history
๋กœ๊ทธ์ธ ๊ธฐ๋Šฅ ๋Œ€์‘์„ ์œ„ํ•œ API ์ˆ˜์ •
  • Loading branch information
jxmen authored Aug 4, 2024
2 parents 85edc93 + 9d9c247 commit 15d04b8
Show file tree
Hide file tree
Showing 17 changed files with 336 additions and 187 deletions.
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ plugins {
}

group = "dev.jxmen"
version = "0.4.6"
version = "0.5.0"

java {
sourceCompatibility = JavaVersion.VERSION_21
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ class ChatApi(

@GetMapping("/api/v2/chat/messages")
fun getMessagesV2(
member: Member,
@Param("subjectId") subjectId: String,
): ResponseEntity<ListDataResponse<ChatMessageResponse>> {
val member = httpSession.getAttribute("member") as Member
val subject = subjectQuery.findById(subjectId.toLong())

val messages =
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
package dev.jxmen.cs.ai.interviewer.adapter.input

import jakarta.servlet.http.HttpSession
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController

@RestController
class IsLoggedInApi {
@Deprecated("๊ตฌ๊ธ€ ๋กœ๊ทธ์ธ ์ ์šฉ ํ›„ ์‚ญ์ œํ•  ์˜ˆ์ •")
@GetMapping("/api/v1/is-logged-in")
fun isLoggedIn(httpSession: HttpSession): ResponseEntity<IsLoggedInResponse> {
val isLoggedIn = httpSession.getAttribute("member") != null

return ResponseEntity.ok(IsLoggedInResponse(isLoggedIn = isLoggedIn))
}
fun isLoggedIn(): ResponseEntity<IsLoggedInResponse> = ResponseEntity.ok(IsLoggedInResponse(isLoggedIn = false))
}

data class IsLoggedInResponse(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,13 @@ class SubjectApi(
return ResponseEntity.status(201).body(res)
}

@PostMapping("/api/v2/subjects/{id}/answer")
@PostMapping("/api/v2/subjects/{subjectId}/answer")
fun answerSubjectV2(
@PathVariable("id") id: String,
member: Member,
@PathVariable("subjectId") subjectId: String,
@RequestBody @Valid req: SubjectAnswerRequest,
): ResponseEntity<SubjectAnswerResponse> {
val subject = subjectQuery.findById(id.toLong())
val member = httpSession.getAttribute("member") as Member
val subject = subjectQuery.findById(subjectId.toLong())

val res =
subjectUseCase.answerV2(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ class Member(
@Comment("๋กœ๊ทธ์ธ ํƒ€์ž…")
val loginType: MemberLoginType,

) : BaseEntity(), Serializable {
) : BaseEntity(),
Serializable {
companion object {
fun createGoogleMember(
name: String,
Expand All @@ -40,5 +41,16 @@ class Member(
email = email,
loginType = MemberLoginType.GOOGLE,
)

fun createWithId(
id: Long,
name: String,
email: String,
loginType: MemberLoginType,
): Member {
val member = Member(name, email, loginType)
member.id = id
return member
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package dev.jxmen.cs.ai.interviewer.domain.member

import org.springframework.core.MethodParameter
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.oauth2.core.user.OAuth2User
import org.springframework.web.bind.support.WebDataBinderFactory
import org.springframework.web.context.request.NativeWebRequest
import org.springframework.web.method.support.HandlerMethodArgumentResolver
import org.springframework.web.method.support.ModelAndViewContainer

class MemberArgumentResolver(
private val memberQueryRepository: MemberQueryRepository,
private val memberCommandRepository: MemberCommandRepository,
) : HandlerMethodArgumentResolver {
override fun supportsParameter(parameter: MethodParameter): Boolean = parameter.parameterType == Member::class.java

override fun resolveArgument(
parameter: MethodParameter,
mavContainer: ModelAndViewContainer?,
webRequest: NativeWebRequest,
binderFactory: WebDataBinderFactory?,
): Member {
val authentication = SecurityContextHolder.getContext().authentication
val oAuth2User = authentication.principal as OAuth2User
val attributes = oAuth2User.attributes

val name = attributes["name"].toString()
val email = attributes["email"].toString()

// NOTE: ๊ตฌ๊ธ€ ์™ธ ๋‹ค๋ฅธ ๋กœ๊ทธ์ธ ์ˆ˜๋‹จ ์ถ”๊ฐ€ ์‹œ ์•„๋ž˜ ๋กœ์ง ๋ณ€๊ฒฝ ํ•„์š”
return memberQueryRepository.findByEmailOrNull(email)
?: memberCommandRepository.save(Member.createGoogleMember(name, email))
}

fun MemberQueryRepository.findByEmailOrNull(email: String): Member? = findByEmail(email).orElse(null)
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
package dev.jxmen.cs.ai.interviewer.global.config

import dev.jxmen.cs.ai.interviewer.domain.member.MemberArgumentResolver
import dev.jxmen.cs.ai.interviewer.domain.member.MemberCommandRepository
import dev.jxmen.cs.ai.interviewer.domain.member.MemberQueryRepository
import org.springframework.context.annotation.Configuration
import org.springframework.web.method.support.HandlerMethodArgumentResolver
import org.springframework.web.servlet.config.annotation.CorsRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer

@Configuration
class WebConfig : WebMvcConfigurer {
class WebConfig(
private val memberQueryRepository: MemberQueryRepository,
private val memberCommandRepository: MemberCommandRepository,
) : WebMvcConfigurer {
override fun addCorsMappings(registry: CorsRegistry) {
registry
.addMapping("/**")
Expand All @@ -14,4 +21,13 @@ class WebConfig : WebMvcConfigurer {
.allowedMethods("GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS")
.allowedHeaders("*")
}

override fun addArgumentResolvers(resolvers: MutableList<HandlerMethodArgumentResolver>) {
resolvers.add(
MemberArgumentResolver(
memberQueryRepository = memberQueryRepository,
memberCommandRepository = memberCommandRepository,
),
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package dev.jxmen.cs.ai.interviewer.global.config.security

import jakarta.servlet.FilterChain
import jakarta.servlet.ServletException
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.security.web.csrf.CsrfToken
import org.springframework.web.filter.OncePerRequestFilter
import java.io.IOException

class CsrfCookieFilter : OncePerRequestFilter() {
@Throws(ServletException::class, IOException::class)
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain,
) {
val csrfToken = request.getAttribute("_csrf") as CsrfToken
// Render the token value to a cookie by causing the deferred token to be loaded
csrfToken.token
filterChain.doFilter(request, response)
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
package dev.jxmen.cs.ai.interviewer.global.config
package dev.jxmen.cs.ai.interviewer.global.config.security

import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import dev.jxmen.cs.ai.interviewer.global.config.service.CustomOAuth2UserService
import dev.jxmen.cs.ai.interviewer.global.dto.ErrorResponse
import jakarta.servlet.FilterChain
import jakarta.servlet.ServletException
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
Expand All @@ -18,22 +13,15 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter
import org.springframework.security.web.csrf.CookieCsrfTokenRepository
import org.springframework.security.web.csrf.CsrfToken
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler
import org.springframework.security.web.csrf.CsrfTokenRequestHandler
import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler
import org.springframework.util.StringUtils
import org.springframework.web.filter.OncePerRequestFilter
import java.io.IOException
import java.util.function.Supplier

@Suppress("ktlint:standard:chain-method-continuation") // NOTE: ํ™œ์„ฑํ™”์‹œ ์˜คํžˆ๋ ค ๊ฐ€๋…์„ฑ์ด ์ €ํ•˜๋˜์–ด ๋น„ํ™œ์„ฑํ™”
@EnableWebSecurity
@Configuration
class SecurityConfig(
@Value("\${spring.profiles.active:default}") // NOTE: ๊ฐ’์ด ์—†์„์‹œ ์ฝœ๋ก  ๋’ค์— ๊ธฐ๋ณธ๊ฐ’ ์ง€์ • ๊ฐ€๋Šฅ
private val activeProfile: String,
private val customOAuth2UserService: CustomOAuth2UserService,
) {
companion object {
private val objectMapper = jacksonObjectMapper()
Expand All @@ -50,30 +38,35 @@ class SecurityConfig(

http
.csrf {
it.ignoringRequestMatchers("/h2-console/**")
it.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
it.csrfTokenRequestHandler(SpaCsrfTokenRequestHandler())
it.csrfTokenRequestHandler(spaCsrfTokenRequestHandler())
}
.authorizeHttpRequests {
// resources and public pages
it
// == permitAll ==
.requestMatchers("/h2-console/**").permitAll()
.requestMatchers("/").permitAll()
.requestMatchers(HttpMethod.GET, "/error").permitAll()
.requestMatchers(HttpMethod.GET, "/favicon.ico").permitAll()
.requestMatchers(HttpMethod.GET, "/swagger-ui/**").permitAll()

// public API
it
.requestMatchers(HttpMethod.GET, "/api/version").permitAll()
.requestMatchers(HttpMethod.GET, "/api/test/session-id").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/is-logged-in").permitAll()
.requestMatchers(HttpMethod.GET, "/api/subjects").permitAll()
.requestMatchers(HttpMethod.GET, "/api/subjects/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/subjects/{subjectId}/answer").permitAll()
.requestMatchers(HttpMethod.GET, "/api/chat/messages").permitAll()
// === authenticated ===
.requestMatchers(HttpMethod.POST, "/api/v2/subjects/{subjectId}/answer").authenticated()
.requestMatchers(HttpMethod.GET, "/api/v2/chat/messages").authenticated()
.anyRequest().authenticated()
}
.oauth2Login {
it.userInfoEndpoint { it.userService(customOAuth2UserService) }

// v2 API
it
.requestMatchers(HttpMethod.POST, "/api/v2/subjects/{subjectId}/answer").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v2/chat/messages").permitAll()
}
.oauth2Login { }
.exceptionHandling {
it.authenticationEntryPoint { _, response, authException ->
// ์ธ์ฆ๋˜์ง€ ์•Š๊ฑฐ๋‚˜ ์‹คํŒจํ•  ๊ฒฝ์šฐ ๊ณต๊ฐœ๋œ API ์™ธ 401 ์‘๋‹ต
Expand All @@ -89,64 +82,20 @@ class SecurityConfig(
)
}
}

http.addFilterAfter(CsrfCookieFilter(), BasicAuthenticationFilter::class.java)
.addFilterBefore(tokenFilter(), BasicAuthenticationFilter::class.java)
.addFilterAfter(csrfCookieFilter(), BasicAuthenticationFilter::class.java)

return http.build()
}

private fun toJson(obj: Any): String = objectMapper.writeValueAsString(obj)
}

class SpaCsrfTokenRequestHandler : CsrfTokenRequestAttributeHandler() {
private val delegate: CsrfTokenRequestHandler = XorCsrfTokenRequestAttributeHandler()
@Bean
fun tokenFilter(): OncePerRequestFilter = TokenFilter()

override fun handle(
request: HttpServletRequest,
response: HttpServletResponse,
csrfToken: Supplier<CsrfToken>,
) {
/*
* Always use XorCsrfTokenRequestAttributeHandler to provide BREACH protection of
* the CsrfToken when it is rendered in the response body.
*/
delegate.handle(request, response, csrfToken)
}
@Bean
fun spaCsrfTokenRequestHandler(): CsrfTokenRequestHandler = SpaCsrfTokenRequestHandler()

override fun resolveCsrfTokenValue(
request: HttpServletRequest,
csrfToken: CsrfToken,
): String? {
/*
* If the request contains a request header, use CsrfTokenRequestAttributeHandler
* to resolve the CsrfToken. This applies when a single-page application includes
* the header value automatically, which was obtained via a cookie containing the
* raw CsrfToken.
*/
return if (StringUtils.hasText(request.getHeader(csrfToken.headerName))) {
super.resolveCsrfTokenValue(request, csrfToken)
} else {
/*
* In all other cases (e.g. if the request contains a request parameter), use
* XorCsrfTokenRequestAttributeHandler to resolve the CsrfToken. This applies
* when a server-side rendered form includes the _csrf request parameter as a
* hidden input.
*/
delegate.resolveCsrfTokenValue(request, csrfToken)
}
}
}
@Bean
fun csrfCookieFilter(): CsrfCookieFilter = CsrfCookieFilter()

class CsrfCookieFilter : OncePerRequestFilter() {
@Throws(ServletException::class, IOException::class)
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain,
) {
val csrfToken = request.getAttribute("_csrf") as CsrfToken
// Render the token value to a cookie by causing the deferred token to be loaded
csrfToken.token
filterChain.doFilter(request, response)
}
private fun toJson(obj: Any): String = objectMapper.writeValueAsString(obj)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package dev.jxmen.cs.ai.interviewer.global.config.security

import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.security.web.csrf.CsrfToken
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler
import org.springframework.security.web.csrf.CsrfTokenRequestHandler
import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler
import org.springframework.util.StringUtils
import java.util.function.Supplier

class SpaCsrfTokenRequestHandler : CsrfTokenRequestAttributeHandler() {
private val delegate: CsrfTokenRequestHandler = XorCsrfTokenRequestAttributeHandler()

override fun handle(
request: HttpServletRequest,
response: HttpServletResponse,
csrfToken: Supplier<CsrfToken>,
) {
/*
* Always use XorCsrfTokenRequestAttributeHandler to provide BREACH protection of
* the CsrfToken when it is rendered in the response body.
*/
delegate.handle(request, response, csrfToken)
}

override fun resolveCsrfTokenValue(
request: HttpServletRequest,
csrfToken: CsrfToken,
): String? {
/*
* If the request contains a request header, use CsrfTokenRequestAttributeHandler
* to resolve the CsrfToken. This applies when a single-page application includes
* the header value automatically, which was obtained via a cookie containing the
* raw CsrfToken.
*/
return if (StringUtils.hasText(request.getHeader(csrfToken.headerName))) {
super.resolveCsrfTokenValue(request, csrfToken)
} else {
/*
* In all other cases (e.g. if the request contains a request parameter), use
* XorCsrfTokenRequestAttributeHandler to resolve the CsrfToken. This applies
* when a server-side rendered form includes the _csrf request parameter as a
* hidden input.
*/
delegate.resolveCsrfTokenValue(request, csrfToken)
}
}
}
Loading

0 comments on commit 15d04b8

Please sign in to comment.