Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,11 @@ package io.deck.iam.api

interface OAuthProviderQuery {
fun isProviderConfigured(providerType: String): Boolean

fun getGoogleSiteVerification(): GoogleSiteVerificationSnapshot?

data class GoogleSiteVerificationSnapshot(
val metaVerificationToken: String? = null,
val htmlVerificationFileName: String? = null,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package io.deck.iam.controller

import io.deck.iam.api.OAuthProviderQuery
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RestController

/**
* Google Site Verification (HTML 파일 확인 방식)
*
* Google이 지정하는 /google{token}.html 경로에 인증 텍스트를 응답한다.
* 설정은 OAuthProviderConfig.Google.WebsiteVerification에서 관리한다.
*/
@RestController
@PreAuthorize("permitAll()")
class GoogleSiteVerificationController(
private val oauthProviderQuery: OAuthProviderQuery,
) {
@GetMapping("/{filename:google[a-z0-9]+\\.html}")
fun verify(
@PathVariable filename: String,
): ResponseEntity<String> {
val snapshot = oauthProviderQuery.getGoogleSiteVerification()
?: return ResponseEntity.notFound().build()

val expected = snapshot.htmlVerificationFileName
?: return ResponseEntity.notFound().build()

if (filename != expected) {
return ResponseEntity.notFound().build()
}

return ResponseEntity
.ok()
.contentType(MediaType.TEXT_PLAIN)
.body("google-site-verification: $expected")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ fun OAuthProviderEntity.toDto(): OAuthProviderDto =

fun UpdateOAuthProviderRequest.toConfig(provider: OAuthProviderType): OAuthProviderConfig? =
when (provider) {
OAuthProviderType.GOOGLE -> OAuthProviderConfig.Google
OAuthProviderType.GOOGLE -> OAuthProviderConfig.Google()
OAuthProviderType.NAVER -> OAuthProviderConfig.Naver
OAuthProviderType.KAKAO -> OAuthProviderConfig.Kakao
OAuthProviderType.OKTA -> domain?.let { OAuthProviderConfig.Okta(it) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,15 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo
JsonSubTypes.Type(value = OAuthProviderConfig.Aip::class, name = "AIP"),
)
sealed class OAuthProviderConfig {
/** Google: 추가 설정 없음 */
data object Google : OAuthProviderConfig()
/** Google: 사이트 검증 설정 */
data class Google(
val websiteVerification: WebsiteVerification? = null,
) : OAuthProviderConfig() {
data class WebsiteVerification(
val metaVerificationToken: String? = null,
val htmlVerificationFileName: String? = null,
)
}

/** Naver: 추가 설정 없음 */
data object Naver : OAuthProviderConfig()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.deck.iam.service

import io.deck.iam.api.OAuthProviderQuery
import io.deck.iam.domain.OAuthProviderConfig
import io.deck.iam.domain.OAuthProviderType
import org.springframework.stereotype.Service

Expand All @@ -12,4 +13,13 @@ class OAuthProviderQueryImpl(
val type = OAuthProviderType.entries.find { it.name == providerType } ?: return false
return oauthProviderService.get(type)?.isConfigured() ?: false
}

override fun getGoogleSiteVerification(): OAuthProviderQuery.GoogleSiteVerificationSnapshot? {
val entity = oauthProviderService.get(OAuthProviderType.GOOGLE) ?: return null
val verification = (entity.config as? OAuthProviderConfig.Google)?.websiteVerification ?: return null
return OAuthProviderQuery.GoogleSiteVerificationSnapshot(
metaVerificationToken = verification.metaVerificationToken,
htmlVerificationFileName = verification.htmlVerificationFileName,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,15 @@ class OAuthProviderService(
oauthProviderRepository.findById(type).orElseGet {
OAuthProviderEntity(provider = type)
}
val mergedConfig = mergeProviderConfig(type, entity.config, config)

// 활성화된 Provider는 필수값 검증
if (enabled) {
val req = ProviderSaveRequest(type, enabled, clientId, clientSecret, config)
val req = ProviderSaveRequest(type, enabled, clientId, clientSecret, mergedConfig)
validateProviderConfig(req, entity)
}

entity.update(enabled, clientId, clientSecret, config)
entity.update(enabled, clientId, clientSecret, mergedConfig)

// Lockout 방지 검증
validateOwnerCanLogin(entity)
Expand All @@ -94,13 +95,14 @@ class OAuthProviderService(
oauthProviderRepository.findById(req.type).orElseGet {
OAuthProviderEntity(provider = req.type)
}
val mergedConfig = mergeProviderConfig(req.type, entity.config, req.config)

// 활성화된 Provider는 필수값 검증
if (req.enabled) {
validateProviderConfig(req, entity)
validateProviderConfig(req.copy(config = mergedConfig), entity)
}

entity.update(req.enabled, req.clientId, req.clientSecret, req.config)
entity.update(req.enabled, req.clientId, req.clientSecret, mergedConfig)
entity
}

Expand Down Expand Up @@ -201,6 +203,28 @@ class OAuthProviderService(
val config: OAuthProviderConfig?,
)

private fun mergeProviderConfig(
type: OAuthProviderType,
existingConfig: OAuthProviderConfig?,
requestConfig: OAuthProviderConfig?,
): OAuthProviderConfig? =
when (type) {
OAuthProviderType.GOOGLE -> {
val existingGoogle = existingConfig as? OAuthProviderConfig.Google
val requestGoogle = requestConfig as? OAuthProviderConfig.Google

when {
requestGoogle == null -> existingGoogle
requestGoogle.websiteVerification != null -> requestGoogle
else -> requestGoogle.copy(websiteVerification = existingGoogle?.websiteVerification)
}
}

else -> {
requestConfig
}
}

private fun publishProviderActivity(entity: OAuthProviderEntity) {
eventPublisher.publishEvent(
currentUserActivityEvent(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ class DynamicClientRegistrationRepositoryTest :
enabled = true,
clientId = "google-id",
clientSecret = "google-secret",
config = OAuthProviderConfig.Google,
config = OAuthProviderConfig.Google(),
)

val repository = DynamicClientRegistrationRepository(oauthProviderRepository)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package io.deck.iam.controller

import io.deck.iam.api.OAuthProviderQuery
import io.kotest.core.spec.style.DescribeSpec
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType

class GoogleSiteVerificationControllerTest :
DescribeSpec({
lateinit var oauthProviderQuery: OAuthProviderQuery
lateinit var controller: GoogleSiteVerificationController

beforeEach {
oauthProviderQuery = mockk(relaxed = true)
controller = GoogleSiteVerificationController(oauthProviderQuery)
}

describe("Google Site Verification") {
it("설정된 파일명과 일치하면 검증 응답을 반환해야 한다") {
// given
every { oauthProviderQuery.getGoogleSiteVerification() } returns
OAuthProviderQuery.GoogleSiteVerificationSnapshot(
htmlVerificationFileName = "google12cfc68677988bb4.html",
)

// when
val response = controller.verify("google12cfc68677988bb4.html")

// then
response.statusCode shouldBe HttpStatus.OK
response.headers.contentType shouldBe MediaType.TEXT_PLAIN
response.body shouldBe "google-site-verification: google12cfc68677988bb4.html"
}

it("파일명이 일치하지 않으면 404를 반환해야 한다") {
// given
every { oauthProviderQuery.getGoogleSiteVerification() } returns
OAuthProviderQuery.GoogleSiteVerificationSnapshot(
htmlVerificationFileName = "google12cfc68677988bb4.html",
)

// when
val response = controller.verify("googleabc123.html")

// then
response.statusCode shouldBe HttpStatus.NOT_FOUND
}

it("검증 설정이 없으면 404를 반환해야 한다") {
// given
every { oauthProviderQuery.getGoogleSiteVerification() } returns null

// when
val response = controller.verify("google12cfc68677988bb4.html")

// then
response.statusCode shouldBe HttpStatus.NOT_FOUND
}

it("htmlVerificationFileName이 null이면 404를 반환해야 한다") {
// given
every { oauthProviderQuery.getGoogleSiteVerification() } returns
OAuthProviderQuery.GoogleSiteVerificationSnapshot(
metaVerificationToken = "some-token",
htmlVerificationFileName = null,
)

// when
val response = controller.verify("google12cfc68677988bb4.html")

// then
response.statusCode shouldBe HttpStatus.NOT_FOUND
}
}
})
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,95 @@ class OAuthProviderServiceTest :
result.size shouldBe 2
verify { oauthProviderRepository.saveAll(any<List<OAuthProviderEntity>>()) }
}

it("Google provider 일괄 저장 시 기존 사이트 검증 설정을 유지해야 한다") {
// given
val owner = createOwner()
val googleIdentity = createIdentity(owner, AuthProvider.GOOGLE)
val settings = SystemSettingEntity(internalLoginEnabled = false)
val existingGoogle =
OAuthProviderEntity(
provider = OAuthProviderType.GOOGLE,
enabled = true,
clientId = "google-id",
clientSecret = "google-secret",
config =
OAuthProviderConfig.Google(
websiteVerification =
OAuthProviderConfig.Google.WebsiteVerification(
htmlVerificationFileName = "google12cfc68677988bb4.html",
),
),
)

every { oauthProviderRepository.findById(OAuthProviderType.GOOGLE) } returns Optional.of(existingGoogle)
every { oauthProviderRepository.saveAll(any<List<OAuthProviderEntity>>()) } answers { firstArg() }
every { systemSettingService.getSettings() } returns settings
every { userRepository.findOwners() } returns listOf(owner)
every { identityService.findAllByUserId(owner.id) } returns listOf(googleIdentity)

// when
val result =
oauthProviderService.saveAll(
listOf(
OAuthProviderService.ProviderSaveRequest(
type = OAuthProviderType.GOOGLE,
enabled = true,
clientId = "google-id",
clientSecret = "",
config = OAuthProviderConfig.Google(),
),
),
)

// then
(result.single().config as OAuthProviderConfig.Google).websiteVerification?.htmlVerificationFileName shouldBe
"google12cfc68677988bb4.html"
}
}

describe("Google 사이트 검증 설정 보존") {
it("Google provider 단건 저장 시 기존 사이트 검증 설정을 유지해야 한다") {
// given
val owner = createOwner()
val identity = createIdentity(owner, AuthProvider.GOOGLE)
val settings = SystemSettingEntity(internalLoginEnabled = false)
val existingGoogle =
OAuthProviderEntity(
provider = OAuthProviderType.GOOGLE,
enabled = true,
clientId = "google-id",
clientSecret = "google-secret",
config =
OAuthProviderConfig.Google(
websiteVerification =
OAuthProviderConfig.Google.WebsiteVerification(
htmlVerificationFileName = "google12cfc68677988bb4.html",
),
),
)

every { oauthProviderRepository.findById(OAuthProviderType.GOOGLE) } returns Optional.of(existingGoogle)
every { oauthProviderRepository.findAll() } returns emptyList()
every { oauthProviderRepository.save(any()) } answers { firstArg() }
every { systemSettingService.getSettings() } returns settings
every { userRepository.findOwners() } returns listOf(owner)
every { identityService.findAllByUserId(owner.id) } returns listOf(identity)

// when
val result =
oauthProviderService.save(
type = OAuthProviderType.GOOGLE,
enabled = true,
clientId = "google-id",
clientSecret = "",
config = OAuthProviderConfig.Google(),
)

// then
(result.config as OAuthProviderConfig.Google).websiteVerification?.htmlVerificationFileName shouldBe
"google12cfc68677988bb4.html"
}
}

describe("activity log 이벤트 발행") {
Expand Down
2 changes: 2 additions & 0 deletions frontend/app/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { AuthorizationProvider } from '#app/shared/authorization';
import { auth } from '#app/features/auth';
import { OverlayProvider } from '#app/shared/overlay';

const LandingPage = lazy(() => import('#app/pages/landing/landing.page'));
const LoginPage = lazy(() => import('#app/pages/login/login.page'));
const InvitePage = lazy(() => import('#app/pages/invite/invite.page'));
const WorkspaceInvitePage = lazy(() => import('#app/pages/workspace-invite/workspace-invite.page'));
Expand Down Expand Up @@ -149,6 +150,7 @@ function StandalonePageRoute({ pathname }: { pathname: string }) {
}

const standaloneRoutes: RouteObject[] = [
{ path: '/', element: withStandaloneFallback(<LandingPage />) },
{ path: '/login/*', element: withStandaloneFallback(<LoginPage />) },
{ path: '/invite/*', element: withStandaloneFallback(<InvitePage />) },
{ path: '/workspace-invite/*', element: withStandaloneFallback(<WorkspaceInvitePage />) },
Expand Down
3 changes: 3 additions & 0 deletions frontend/app/src/app/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ describe('App overlay dismiss 통합', () => {
sidebarCollapsed.set(false);
isInitialized.set(true);
isLoading.set(false);

// / 경로는 LandingPage를 렌더링하므로 AppShell 테스트는 /dashboard에서 시작
window.history.replaceState({}, '', '/dashboard');
});

afterEach(() => {
Expand Down
Loading
Loading