Skip to content
Merged
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
14 changes: 8 additions & 6 deletions api/src/main/kotlin/controller/CoursebookController.kt
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
package com.wafflestudio.snutt.controller

import com.wafflestudio.snutt.common.enums.Semester
import com.wafflestudio.snutt.common.util.SugangSnuUrlUtils.REDIRECT_PREFIX_URL
import com.wafflestudio.snutt.common.util.SugangSnuUrlUtils.parseSyllabusUrl
import com.wafflestudio.snutt.common.util.SugangSnuUrlUtils.SUGANG_SNU_BASE_URL
import com.wafflestudio.snutt.common.util.SugangSnuUrlUtils.parseSyllabusPath
import com.wafflestudio.snutt.coursebook.data.CoursebookOfficialResponse
import com.wafflestudio.snutt.coursebook.data.CoursebookResponse
import com.wafflestudio.snutt.coursebook.service.CoursebookService
import com.wafflestudio.snutt.filter.SnuttNoAuthApiFilterTarget
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.MediaType
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
Expand All @@ -22,6 +23,7 @@ import org.springframework.web.bind.annotation.RestController
)
class CoursebookController(
private val coursebookService: CoursebookService,
@param:Value("\${snutt.syllabus-proxy.base-url}") private val syllabusProxyBaseUrl: String,
) {
@GetMapping("")
suspend fun getCoursebooks() = coursebookService.getCoursebooks().map { CoursebookResponse(it) }
Expand All @@ -39,16 +41,16 @@ class CoursebookController(
@RequestParam("course_number") courseNumber: String,
@RequestParam("lecture_number") lectureNumber: String,
): CoursebookOfficialResponse {
val url =
parseSyllabusUrl(
val syllabusPath =
parseSyllabusPath(
year = year,
semester = semester,
courseNumber = courseNumber,
lectureNumber = lectureNumber,
)
return CoursebookOfficialResponse(
noProxyUrl = url,
proxyUrl = REDIRECT_PREFIX_URL + url,
noProxyUrl = SUGANG_SNU_BASE_URL + syllabusPath,
proxyUrl = syllabusProxyBaseUrl + syllabusPath,
)
}
}
2 changes: 2 additions & 0 deletions api/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ spring:

snutt:
secret-key:
syllabus-proxy:
base-url: https://snutt-proxy.wafflestudio.com

springdoc:
paths-to-match: /v1/**
Expand Down
6 changes: 2 additions & 4 deletions core/src/main/kotlin/common/util/SugangSnuUrlUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import com.wafflestudio.snutt.common.enums.Semester
import org.springframework.web.util.DefaultUriBuilderFactory

object SugangSnuUrlUtils {
const val REDIRECT_PREFIX_URL = "https://libproxy.snu.ac.kr/_Lib_Proxy_Url/"
const val SUGANG_SNU_BASE_URL = "https://sugang.snu.ac.kr"

fun convertSemesterToSugangSnuSearchString(semester: Semester): String =
when (semester) {
Expand All @@ -14,16 +14,14 @@ object SugangSnuUrlUtils {
Semester.WINTER -> "U000200002U000300002"
}

fun parseSyllabusUrl(
fun parseSyllabusPath(
year: Int,
semester: Semester,
courseNumber: String,
lectureNumber: String,
): String =
DefaultUriBuilderFactory()
.builder()
.scheme("https")
.host("sugang.snu.ac.kr")
.path("/sugang/cc/cc103.action")
.queryParam("openSchyy", year)
.queryParam("openShtmFg", makeOpenShtmFg(semester))
Expand Down
6 changes: 5 additions & 1 deletion core/src/main/kotlin/mail/service/MailService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ interface MailService {
to: String,
code: String = "",
localId: String = "",
accountInfo: String = "",
)
}

Expand All @@ -32,11 +33,12 @@ class MailServiceImpl(
to: String,
code: String,
localId: String,
accountInfo: String,
) {
if (!authService.isValidEmail(to)) {
throw InvalidEmailException
}
getUserMailContent(type, to, code, localId).let { (subject, body) ->
getUserMailContent(type, to, code, localId, accountInfo).let { (subject, body) ->
mailClient.sendMail(to, subject, body)
}
}
Expand All @@ -46,6 +48,7 @@ class MailServiceImpl(
email: String,
code: String,
localId: String,
accountInfo: String,
): MailContent {
val mailContentList =
mailTemplateResource.inputStream
Expand All @@ -54,6 +57,7 @@ class MailServiceImpl(
.replace("{code}", code)
.replace("{email}", email)
.replace("{localId}", localId)
.replace("{accountInfo}", accountInfo)
.split("\n\n")
.windowed(2, 2, false)
.map { (subject, body) -> MailContent(subject, body) }
Expand Down
7 changes: 7 additions & 0 deletions core/src/main/kotlin/users/data/User.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,17 @@ import org.springframework.data.mongodb.core.index.Indexed
import org.springframework.data.mongodb.core.mapping.Document
import java.time.LocalDateTime

const val EMAIL_CASE_INSENSITIVE_COLLATION = "{ 'locale': 'en', 'strength': 2 }"

@Document("users")
data class User(
@Id
val id: String? = null,
@Indexed(
unique = true,
partialFilter = $$"{ 'active': true, 'isEmailVerified': true, 'email': { '$type': 'string' } }",
collation = EMAIL_CASE_INSENSITIVE_COLLATION,
)
var email: String?,
@Indexed(unique = true, sparse = true)
var nickname: String,
Expand Down
23 changes: 19 additions & 4 deletions core/src/main/kotlin/users/repository/UserRepository.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.wafflestudio.snutt.users.repository

import com.wafflestudio.snutt.users.data.EMAIL_CASE_INSENSITIVE_COLLATION
import com.wafflestudio.snutt.users.data.User
import kotlinx.coroutines.flow.Flow
import org.springframework.data.mongodb.repository.Query
import org.springframework.data.repository.kotlin.CoroutineCrudRepository

interface UserRepository : CoroutineCrudRepository<User, String> {
Expand Down Expand Up @@ -30,10 +32,23 @@ interface UserRepository : CoroutineCrudRepository<User, String> {

suspend fun findAllByIdInAndActiveTrue(ids: List<String>): List<User>

suspend fun existsByEmailIgnoreCaseAndIsEmailVerifiedTrueAndActiveTrue(email: String): Boolean

suspend fun findByEmailIgnoreCaseAndIsEmailVerifiedTrueAndActiveTrue(email: String): User?

@Query(
value = "{ 'email': ?0, 'isEmailVerified': true, 'active': true }",
exists = true,
collation = EMAIL_CASE_INSENSITIVE_COLLATION,
)
suspend fun existsByEmailAndIsEmailVerifiedTrueAndActiveTrue(email: String): Boolean

@Query(
value = "{ 'email': ?0, 'isEmailVerified': true, 'active': true }",
collation = EMAIL_CASE_INSENSITIVE_COLLATION,
)
suspend fun findByEmailAndIsEmailVerifiedTrueAndActiveTrue(email: String): User?

@Query(
value = "{ 'email': ?0 }",
collation = EMAIL_CASE_INSENSITIVE_COLLATION,
)
suspend fun findAllByEmail(email: String): List<User>

suspend fun existsByCredentialFbIdAndActiveTrue(fbId: String): Boolean
Expand Down
7 changes: 5 additions & 2 deletions core/src/main/kotlin/users/service/AuthService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,12 @@ class AuthServiceImpl(

override fun isValidPassword(password: String) = password.matches(passwordRegex)

override fun isValidEmail(email: String) = email.matches(emailRegex)
override fun isValidEmail(email: String) = email.trim().matches(emailRegex)

override fun isValidSnuMail(email: String) = email.matches(emailRegex) && email.endsWith("@snu.ac.kr")
override fun isValidSnuMail(email: String): Boolean {
val email = email.trim()
return email.matches(emailRegex) && email.endsWith("@snu.ac.kr", ignoreCase = true)
}

override fun isMatchedPassword(
user: User,
Expand Down
70 changes: 54 additions & 16 deletions core/src/main/kotlin/users/service/UserService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import com.wafflestudio.snutt.users.repository.UserRepository
import org.slf4j.LoggerFactory
import org.springframework.aot.hint.annotation.RegisterReflectionForBinding
import org.springframework.context.ApplicationEventPublisher
import org.springframework.dao.DuplicateKeyException
import org.springframework.data.redis.core.ReactiveStringRedisTemplate
import org.springframework.data.redis.core.getAndAwait
import org.springframework.stereotype.Service
Expand Down Expand Up @@ -181,7 +182,7 @@ class UserServiceImpl(
override suspend fun registerLocal(localRegisterRequest: LocalRegisterRequest): LoginResponse {
val localId = localRegisterRequest.id
val password = localRegisterRequest.password
val email = localRegisterRequest.email
val email = localRegisterRequest.email?.trim()

val cacheKey = CacheKey.LOCK_REGISTER_LOCAL.build(localId)

Expand All @@ -191,7 +192,7 @@ class UserServiceImpl(
if (!authService.isValidPassword(password)) throw InvalidPasswordException
email?.let {
if (!authService.isValidEmail(email)) throw InvalidEmailException
userRepository.findByEmailIgnoreCaseAndIsEmailVerifiedTrueAndActiveTrue(email)?.let {
userRepository.findByEmailAndIsEmailVerifiedTrueAndActiveTrue(email)?.let {
throw DuplicateEmailException(getAttachedAuthProviders(it))
}
}
Expand Down Expand Up @@ -235,7 +236,7 @@ class UserServiceImpl(
val credential = authService.buildFacebookCredential(oauth2UserResponse)

if (oauth2UserResponse.email != null) {
userRepository.findByEmailIgnoreCaseAndIsEmailVerifiedTrueAndActiveTrue(oauth2UserResponse.email)?.let {
userRepository.findByEmailAndIsEmailVerifiedTrueAndActiveTrue(oauth2UserResponse.email)?.let {
throw DuplicateEmailException(getAttachedAuthProviders(it))
}
} else {
Expand All @@ -259,7 +260,7 @@ class UserServiceImpl(
}

checkNotNull(oauth2UserResponse.email) { "google email is null: $oauth2UserResponse" }
userRepository.findByEmailIgnoreCaseAndIsEmailVerifiedTrueAndActiveTrue(oauth2UserResponse.email)?.let {
userRepository.findByEmailAndIsEmailVerifiedTrueAndActiveTrue(oauth2UserResponse.email)?.let {
throw DuplicateEmailException(getAttachedAuthProviders(it))
}

Expand All @@ -282,7 +283,7 @@ class UserServiceImpl(
}

checkNotNull(oauth2UserResponse.email) { "kakao email is null: $oauth2UserResponse" }
userRepository.findByEmailIgnoreCaseAndIsEmailVerifiedTrueAndActiveTrue(oauth2UserResponse.email)?.let {
userRepository.findByEmailAndIsEmailVerifiedTrueAndActiveTrue(oauth2UserResponse.email)?.let {
throw DuplicateEmailException(getAttachedAuthProviders(it))
}

Expand All @@ -309,7 +310,7 @@ class UserServiceImpl(
}

checkNotNull(oauth2UserResponse.email) { "apple email is null: $oauth2UserResponse" }
userRepository.findByEmailIgnoreCaseAndIsEmailVerifiedTrueAndActiveTrue(oauth2UserResponse.email)?.let {
userRepository.findByEmailAndIsEmailVerifiedTrueAndActiveTrue(oauth2UserResponse.email)?.let {
throw DuplicateEmailException(getAttachedAuthProviders(it))
}

Expand Down Expand Up @@ -349,6 +350,7 @@ class UserServiceImpl(
isEmailVerified: Boolean,
): LoginResponse {
val credentialHash = authService.generateCredentialHash(credential)
val email = email?.trim()

val randomNickname = userNicknameService.generateUniqueRandomNickname()

Expand Down Expand Up @@ -384,12 +386,10 @@ class UserServiceImpl(
user: User,
email: String,
) {
val email = email.trim()
if (user.isEmailVerified == true) throw EmailAlreadyVerifiedException
if (!authService.isValidSnuMail(email)) throw InvalidEmailException
if (userRepository.existsByEmailIgnoreCaseAndIsEmailVerifiedTrueAndActiveTrue(
email,
)
) {
if (userRepository.existsByEmailAndIsEmailVerifiedTrueAndActiveTrue(email)) {
throw DuplicateEmailException(getAttachedAuthProviders(user))
}
val key = VERIFICATION_CODE_PREFIX + user.id
Expand All @@ -408,7 +408,12 @@ class UserServiceImpl(
email = value.email
isEmailVerified = true
}
userRepository.save(user)
try {
userRepository.save(user)
} catch (e: DuplicateKeyException) {
val presentUser = userRepository.findByEmailAndIsEmailVerifiedTrueAndActiveTrue(value.email)
throw DuplicateEmailException(getAttachedAuthProviders(presentUser ?: user))
}
redisTemplate.delete(key).subscribe()
}

Expand Down Expand Up @@ -445,7 +450,7 @@ class UserServiceImpl(
val token = socialLoginRequest.token
val oauth2UserResponse = authService.socialLoginWithAccessToken(authProvider, token)
if (oauth2UserResponse.email != null) {
val presentUser = userRepository.findByEmailIgnoreCaseAndIsEmailVerifiedTrueAndActiveTrue(oauth2UserResponse.email)
val presentUser = userRepository.findByEmailAndIsEmailVerifiedTrueAndActiveTrue(oauth2UserResponse.email)
if (presentUser != null && presentUser.id != user.id) {
throw DuplicateEmailException(getAttachedAuthProviders(presentUser))
}
Expand Down Expand Up @@ -572,13 +577,46 @@ class UserServiceImpl(
}

override suspend fun sendLocalIdToEmail(email: String) {
val user = userRepository.findByEmailIgnoreCaseAndIsEmailVerifiedTrueAndActiveTrue(email) ?: throw UserNotFoundException
mailService.sendUserMail(type = UserMailType.FIND_ID, to = email, localId = user.credential.localId ?: throw UserNotFoundException)
val email = email.trim()
val users = userRepository.findAllByEmail(email).filter { it.active }
if (users.isEmpty()) throw UserNotFoundException

val accountInfo = buildFindIdAccountInfo(users)
if (accountInfo.isBlank()) throw UserNotFoundException
mailService.sendUserMail(type = UserMailType.FIND_ID, to = email, accountInfo = accountInfo)
}

private fun buildFindIdAccountInfo(users: List<User>): String {
val localIds = users.mapNotNull { it.credential.localId }.distinct()
val socialProviders =
users
.flatMap { user ->
listOfNotNull(
user.credential.fbId?.let { "Facebook" },
user.credential.googleSub?.let { "Google" },
user.credential.kakaoSub?.let { "Kakao" },
user.credential.appleSub?.let { "Apple" },
)
}.distinct()

val localIdHtml =
if (localIds.isEmpty()) {
""
} else {
"<h3>아이디</h3><ul>${localIds.joinToString(separator = "") { "<li>$it</li>" }}</ul><br/>"
}
val socialProviderHtml =
socialProviders.joinToString(separator = "") { provider ->
"<b>$provider 로그인으로 가입된 계정이 존재합니다.</b><br/>"
}

return localIdHtml + socialProviderHtml
}

override suspend fun sendResetPasswordCode(email: String) {
val email = email.trim()
if (email.replace(emailMaskRegex, "*") == email) throw UpdateAppVersionException
val user = userRepository.findByEmailIgnoreCaseAndIsEmailVerifiedTrueAndActiveTrue(email) ?: throw UserNotFoundException
val user = userRepository.findByEmailAndIsEmailVerifiedTrueAndActiveTrue(email) ?: throw UserNotFoundException
val key = RESET_PASSWORD_CODE_PREFIX + user.id
val code = Base64.getUrlEncoder().encodeToString(Random.nextBytes(6))
saveNewVerificationValue(email, code, key)
Expand Down Expand Up @@ -617,7 +655,7 @@ class UserServiceImpl(
redisTemplate.delete(RESET_PASSWORD_CODE_PREFIX + user.id).subscribe()
}

override suspend fun getUsersByEmail(email: String): List<User> = userRepository.findAllByEmail(email)
override suspend fun getUsersByEmail(email: String): List<User> = userRepository.findAllByEmail(email.trim())

private suspend fun saveNewVerificationValue(
email: String,
Expand Down
4 changes: 2 additions & 2 deletions core/src/main/resources/userMailTemplate.txt
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@

<h2>아이디 찾기 안내</h2><br/>
안녕하세요. SNUTT입니다. <br/>
<b>{email}로 가입된 아이디는 다음과 같습니다.</b><br/><br/>
<h3>아이디</h3><h3>{localId}</h3><br/><br/>
<b>{email}과 연결된 로그인 정보는 다음과 같습니다.</b><br/><br/>
{accountInfo}<br/><br/>
2 changes: 2 additions & 0 deletions core/src/testFixtures/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ spring.profiles.active: test

snutt:
secret-key: test-secret-key
syllabus-proxy:
base-url: https://snutt-proxy.wafflestudio.com

logging:
level:
Expand Down
Loading