diff --git a/src/main/kotlin/subit/JWTAuth.kt b/src/main/kotlin/subit/JWTAuth.kt index 21c4002..d6ebfff 100644 --- a/src/main/kotlin/subit/JWTAuth.kt +++ b/src/main/kotlin/subit/JWTAuth.kt @@ -1,22 +1,11 @@ package subit -import at.favre.lib.crypto.bcrypt.BCrypt -import com.auth0.jwt.JWT -import com.auth0.jwt.JWTVerifier -import com.auth0.jwt.algorithms.Algorithm import io.ktor.server.application.* import io.ktor.server.auth.* import io.ktor.util.pipeline.* -import kotlinx.serialization.Serializable import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -import subit.console.SimpleAnsiColor.Companion.CYAN -import subit.console.SimpleAnsiColor.Companion.RED +import subit.dataClasses.SsoUserFull import subit.dataClasses.UserFull -import subit.dataClasses.UserId -import subit.database.Users -import subit.logger.ForumLogger -import java.util.* /** * JWT验证 @@ -24,81 +13,5 @@ import java.util.* @Suppress("MemberVisibilityCanBePrivate") object JWTAuth: KoinComponent { - private val logger = ForumLogger.getLogger() - @Serializable - data class Token(val token: String) - - /** - * JWT密钥 - */ - private lateinit var SECRET_KEY: String - - /** - * JWT算法 - */ - private lateinit var algorithm: Algorithm - - /** - * JWT有效期 - */ - private const val VALIDITY: Long = 1000L/*ms*/*60/*s*/*60/*m*/*24/*h*/*7/*d*/ - fun Application.initJwtAuth() - { - // 从配置文件中读取密钥 - val key = environment.config.propertyOrNull("jwt.secret")?.getString() - if (key == null) - { - logger.info("${CYAN}jwt.secret${RED} not found in config file, use random secret key") - SECRET_KEY = UUID.randomUUID().toString() - } - else - { - SECRET_KEY = key - } - // 初始化JWT算法 - algorithm = Algorithm.HMAC512(SECRET_KEY) - } - - /** - * 生成验证器 - */ - fun makeJwtVerifier(): JWTVerifier = JWT.require(algorithm).build() - - /** - * 生成Token - * @param id 用户ID - * @param encryptedPassword 用户密码(加密后) - */ - private fun makeTokenByEncryptedPassword(id: UserId, encryptedPassword: String): Token = JWT.create() - .withSubject("Authentication") - .withClaim("id", id.value) - .withClaim("password", encryptedPassword) - .withExpiresAt(getExpiration()) - .sign(algorithm) - .let(::Token) - - private val users: Users by inject() - - suspend fun checkLoginByEncryptedPassword(id: UserId, encryptedPassword: String): Boolean = - users.getEncryptedPassword(id) == encryptedPassword - - suspend fun checkLogin(id: UserId, password: String): Boolean = - users.getEncryptedPassword(id)?.let { verifyPassword(password, it) } ?: false - suspend fun checkLogin(email: String, password: String): Boolean = - users.getEncryptedPassword(email)?.let { verifyPassword(password, it) } ?: false - - suspend fun makeToken(id: UserId): Token? = - users.getEncryptedPassword(id)?.let { makeTokenByEncryptedPassword(id, it) } - - private fun getExpiration() = Date(System.currentTimeMillis()+VALIDITY) fun PipelineContext<*, ApplicationCall>.getLoginUser(): UserFull? = call.principal() - - private val hasher = BCrypt.with(BCrypt.Version.VERSION_2B) - private val verifier = BCrypt.verifyer(BCrypt.Version.VERSION_2B) - - /** - * 在数据库中保存密码的加密 - */ - fun encryptPassword(password: String): String = hasher.hashToString(12, password.toCharArray()) - fun verifyPassword(password: String, hash: String): Boolean = verifier.verify(password.toCharArray(), hash).verified } diff --git a/src/main/kotlin/subit/config/Config.kt b/src/main/kotlin/subit/config/Config.kt index 7cd6ec4..4fa051a 100644 --- a/src/main/kotlin/subit/config/Config.kt +++ b/src/main/kotlin/subit/config/Config.kt @@ -66,7 +66,6 @@ class ConfigLoader private constructor( fun init() // 初始化所有配置 { apiDocsConfig - emailConfig filesConfig loggerConfig systemConfig diff --git a/src/main/kotlin/subit/config/EmailConfig.kt b/src/main/kotlin/subit/config/EmailConfig.kt deleted file mode 100644 index 94fa1e3..0000000 --- a/src/main/kotlin/subit/config/EmailConfig.kt +++ /dev/null @@ -1,44 +0,0 @@ -package subit.config - -import kotlinx.serialization.Serializable -import kotlinx.serialization.Transient -import net.mamoe.yamlkt.Comment -import java.util.regex.Pattern - -@Serializable -data class EmailConfig( - @Comment("SMTP服务器地址") - val host: String, - @Comment("SMTP服务器端口") - val port: Int, - @Comment("发件人邮箱") - val sender: String, - @Comment("发件人邮箱密码") - val password: String, - @Comment("验证码有效期(秒)") - val codeValidTime: Long, - @Comment("验证邮件标题") - val verifyEmailTitle: String, - @Comment("用户邮箱格式要求(正则表达式)") - val emailFormat: String, - @Comment("是否启用白名单") - val enableWhiteList: Boolean, -) -{ - @Transient - val pattern: Pattern = Pattern.compile(emailFormat) -} - -var emailConfig: EmailConfig by config( - "email.yml", - EmailConfig( - host = "smtp.office365.com", - port = 587, - sender = "example@email.server.com", - password = "your_email_password", - codeValidTime = 600, - verifyEmailTitle = "论坛验证码", - emailFormat = ".*@.*\\..*", - enableWhiteList = false - ) -) \ No newline at end of file diff --git a/src/main/kotlin/subit/config/SystemConfig.kt b/src/main/kotlin/subit/config/SystemConfig.kt index 6be81d2..a50ec3e 100644 --- a/src/main/kotlin/subit/config/SystemConfig.kt +++ b/src/main/kotlin/subit/config/SystemConfig.kt @@ -6,7 +6,13 @@ import net.mamoe.yamlkt.Comment @Serializable data class SystemConfig( @Comment("是否在系统维护中") - val systemMaintaining: Boolean + val systemMaintaining: Boolean, + @Comment("SSO服务器地址, 请不要以/结尾, 例如: https://ssubito.subit.org.cn/api " + + "而不是 https://ssubito.subit.org.cn/api/") + val ssoServer: String, ) -var systemConfig: SystemConfig by config("system.yml", SystemConfig(false)) \ No newline at end of file +var systemConfig: SystemConfig by config( + "system.yml", + SystemConfig(false, "https://ssubito.subit.org.cn/api") +) \ No newline at end of file diff --git a/src/main/kotlin/subit/console/command/CommandSet.kt b/src/main/kotlin/subit/console/command/CommandSet.kt index a47481c..8dedcdd 100644 --- a/src/main/kotlin/subit/console/command/CommandSet.kt +++ b/src/main/kotlin/subit/console/command/CommandSet.kt @@ -30,7 +30,6 @@ object CommandSet: TreeCommand( Logger, Shell, Color, - Whitelist, Maintain, TestDatabase ) diff --git a/src/main/kotlin/subit/console/command/TestDatabase.kt b/src/main/kotlin/subit/console/command/TestDatabase.kt index 7183e28..454ee4d 100644 --- a/src/main/kotlin/subit/console/command/TestDatabase.kt +++ b/src/main/kotlin/subit/console/command/TestDatabase.kt @@ -26,7 +26,6 @@ object TestDatabase: Command, KoinComponent "BannedWords" to dao(), "Blocks" to dao(), "Comments" to dao(), - "EmailCodes" to dao(), "Likes" to dao(), "Notices" to dao(), "Operations" to dao(), @@ -37,7 +36,6 @@ object TestDatabase: Command, KoinComponent "Reports" to dao(), "Stars" to dao(), "Users" to dao(), - "Whitelists" to dao(), ) } diff --git a/src/main/kotlin/subit/console/command/Whitelist.kt b/src/main/kotlin/subit/console/command/Whitelist.kt deleted file mode 100644 index 9fd5f31..0000000 --- a/src/main/kotlin/subit/console/command/Whitelist.kt +++ /dev/null @@ -1,79 +0,0 @@ -package subit.console.command - -import kotlinx.coroutines.runBlocking -import org.jline.reader.Candidate -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -import subit.console.AnsiStyle -import subit.console.SimpleAnsiColor -import subit.database.Whitelists - -object Whitelist: TreeCommand(Add, Remove, Get), KoinComponent -{ - private val whitelists: Whitelists by inject() - override val description: String - get() = "Whitelist manage." - - object Add: Command - { - override val description: String - get() = "Add an email to whitelist." - override val args: String - get() = "" - - override suspend fun execute(args: List): Boolean - { - if (args.size != 1) return false - runBlocking { - whitelists.add(args[0]) - } - CommandSet.out.println("添加成功") - return true - } - } - - object Remove: Command - { - override val description: String - get() = "Remove an email from whitelist." - override val args: String - get() = "" - - override suspend fun execute(args: List): Boolean - { - if (args.size != 1) return false - whitelists.remove(args[0]) - CommandSet.out.println("移除成功") - return true - } - override suspend fun tabComplete(args: List): List - { - if (args.size == 1) - { - return whitelists.getWhitelist().map { Candidate(it) } - } - return emptyList() - } - } - - object Get: Command - { - override val description: String - get() = "列出白名单" - override val args: String - get() = "" - - override suspend fun execute(args: List): Boolean - { - whitelists.getWhitelist().apply { - if (isEmpty()) CommandSet.out.println("白名单为空") - else - { - CommandSet.out.println("白名单:") - forEach{ CommandSet.out.println("${SimpleAnsiColor.GREEN}-${AnsiStyle.RESET} $it") } - } - } - return true - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/subit/dataClasses/UserFull.kt b/src/main/kotlin/subit/dataClasses/UserFull.kt index c63d95f..b08a8df 100644 --- a/src/main/kotlin/subit/dataClasses/UserFull.kt +++ b/src/main/kotlin/subit/dataClasses/UserFull.kt @@ -3,37 +3,108 @@ package subit.dataClasses import io.ktor.server.auth.* import kotlinx.serialization.Serializable +sealed interface SsoUser +{ + val id: UserId + val username: String + val registrationTime: Long +} + +@Serializable +data class SsoUserFull( + override val id: UserId, + override val username: String, + override val registrationTime: Long, + val phone: String, + val email: List, + val studentId: Map, +): SsoUser + +@Serializable +data class SsoUserInfo( + override val id: UserId, + override val username: String, + override val registrationTime: Long, +): SsoUser + /** * 用户数据库数据类 * @property id 用户ID - * @property username 用户名 - * @property email 邮箱(唯一) - * @property registrationTime 注册时间 * @property introduction 个人简介 * @property showStars 是否公开收藏 * @property permission 用户管理权限 * @property filePermission 文件上传权限 */ @Serializable -data class UserFull( +data class DatabaseUser( val id: UserId, - val username: String, - val email: String, - val registrationTime: Long, val introduction: String?, val showStars: Boolean, val permission: PermissionLevel, val filePermission: PermissionLevel -): Principal +) +{ + companion object + { + val example = DatabaseUser( + UserId(1), + "introduction", + true, + PermissionLevel.NORMAL, + PermissionLevel.NORMAL + ) + } +} +fun DatabaseUser?.hasGlobalAdmin() = this != null && (this.permission >= PermissionLevel.ADMIN) +fun UserFull?.hasGlobalAdmin() = this != null && (this.permission >= PermissionLevel.ADMIN) + +sealed interface UserInfo +{ + val id: UserId + val username: String + val registrationTime: Long + val introduction: String? + val showStars: Boolean +} + +@Serializable +data class UserFull( + override val id: UserId, + override val username: String, + override val registrationTime: Long, + val phone: String, + val email: List, + val studentId: Map, + override val introduction: String?, + override val showStars: Boolean, + val permission: PermissionLevel, + val filePermission: PermissionLevel +): Principal, UserInfo { fun toBasicUserInfo() = BasicUserInfo(id, username, registrationTime, introduction, showStars) + fun toSsoUser() = SsoUserFull(id, username, registrationTime, phone, email, studentId) + fun toDatabaseUser() = DatabaseUser(id, introduction, showStars, permission, filePermission) companion object { + fun from(ssoUser: SsoUserFull, dbUser: DatabaseUser) = UserFull( + ssoUser.id, + ssoUser.username, + ssoUser.registrationTime, + ssoUser.phone, + ssoUser.email, + ssoUser.studentId, + dbUser.introduction, + dbUser.showStars, + dbUser.permission, + dbUser.filePermission + ) val example = UserFull( UserId(1), "username", - "email", System.currentTimeMillis(), + "phone", + listOf("email"), + mapOf("studentId" to "studentName"), "introduction", true, PermissionLevel.NORMAL, @@ -41,22 +112,28 @@ data class UserFull( ) } } -fun UserFull?.hasGlobalAdmin() = this != null && (this.permission >= PermissionLevel.ADMIN) /** * 用户基本信息, 即一般人能看到的信息 */ @Serializable data class BasicUserInfo( - val id: UserId, - val username: String, - val registrationTime: Long, - val introduction: String?, - val showStars: Boolean -) + override val id: UserId, + override val username: String, + override val registrationTime: Long, + override val introduction: String?, + override val showStars: Boolean +): UserInfo { companion object { + fun from(ssoUser: SsoUser, dbUser: DatabaseUser) = BasicUserInfo( + ssoUser.id, + ssoUser.username, + ssoUser.registrationTime, + dbUser.introduction, + dbUser.showStars + ) val example = UserFull.example.toBasicUserInfo() } } \ No newline at end of file diff --git a/src/main/kotlin/subit/database/EmailCodes.kt b/src/main/kotlin/subit/database/EmailCodes.kt deleted file mode 100644 index 58c798e..0000000 --- a/src/main/kotlin/subit/database/EmailCodes.kt +++ /dev/null @@ -1,36 +0,0 @@ -package subit.database - -import kotlinx.serialization.Serializable -import subit.database.EmailCodes.EmailCodeUsage -import subit.logger.ForumLogger -import subit.utils.sendEmail - -private val logger = ForumLogger.getLogger() - -interface EmailCodes -{ - @Serializable - enum class EmailCodeUsage(@Transient val description: String) - { - LOGIN("登录"), - REGISTER("注册"), - RESET_PASSWORD("重置密码"), - } - - suspend fun addEmailCode(email: String, code: String, usage: EmailCodeUsage) - - /** - * 验证邮箱验证码,验证成功后将立即删除验证码 - */ - suspend fun verifyEmailCode(email: String, code: String, usage: EmailCodeUsage): Boolean -} - -suspend fun EmailCodes.sendEmailCode(email: String, usage: EmailCodeUsage) -{ - val code = (1..6).map { ('0'..'9').random() }.joinToString("") - sendEmail(email, code, usage).invokeOnCompletion { - if (it != null) logger.severe("发送邮件失败: email: $email, usage: $usage", it) - else logger.info("发送邮件成功: $email, $code, $usage") - } - addEmailCode(email, code, usage) -} \ No newline at end of file diff --git a/src/main/kotlin/subit/database/Users.kt b/src/main/kotlin/subit/database/Users.kt index a4f64b1..051881e 100644 --- a/src/main/kotlin/subit/database/Users.kt +++ b/src/main/kotlin/subit/database/Users.kt @@ -1,49 +1,13 @@ package subit.database import subit.JWTAuth -import subit.dataClasses.PermissionLevel -import subit.dataClasses.Slice -import subit.dataClasses.UserFull -import subit.dataClasses.UserId +import subit.dataClasses.* import subit.database.sqlImpl.UsersImpl +import subit.utils.SSO -/** - * 用户数据库交互类 - * 其中邮箱是唯一的, id唯一且自增在创建用户时不可指定。 - * 用户名可以重复, 不能作为登录/注册的唯一标识。 - * 密码单向加密, 加密算法见[JWTAuth.encryptPassword] - * [UsersImpl]中涉及密码的方法传参均为加密前的密码, 传参前**请不要加密** - */ interface Users { - /** - * 创建用户, 若邮箱已存在则返回null - * @param username 用户名 - * @param password 密码(加密前) - * @param email 邮箱(唯一) - * @return 用户ID - */ - suspend fun createUser( - username: String, - password: String, - email: String, - ): UserId? - - suspend fun getEncryptedPassword(id: UserId): String? - suspend fun getEncryptedPassword(email: String): String? - - suspend fun getUser(id: UserId): UserFull? - suspend fun getUser(email: String): UserFull? - - /** - * 重设密码, 若用户不存在返回false - */ - suspend fun setPassword(email: String, password: String): Boolean - - /** - * 若用户不存在返回false - */ - suspend fun changeUsername(id: UserId, username: String): Boolean + suspend fun getOrCreateUser(id: UserId): DatabaseUser /** * 若用户不存在返回false @@ -64,9 +28,4 @@ interface Users * 若用户不存在返回false */ suspend fun changeFilePermission(id: UserId, permission: PermissionLevel): Boolean - - /** - * 搜索用户 - */ - suspend fun searchUser(username: String, begin: Long, count: Int): Slice } \ No newline at end of file diff --git a/src/main/kotlin/subit/database/Whitelists.kt b/src/main/kotlin/subit/database/Whitelists.kt deleted file mode 100644 index 27faaa0..0000000 --- a/src/main/kotlin/subit/database/Whitelists.kt +++ /dev/null @@ -1,9 +0,0 @@ -package subit.database - -interface Whitelists -{ - suspend fun add(email: String) - suspend fun remove(email: String) - suspend fun isWhitelisted(email: String): Boolean - suspend fun getWhitelist(): List -} \ No newline at end of file diff --git a/src/main/kotlin/subit/database/memoryImpl/EmailCodesImpl.kt b/src/main/kotlin/subit/database/memoryImpl/EmailCodesImpl.kt deleted file mode 100644 index 1b9cc3d..0000000 --- a/src/main/kotlin/subit/database/memoryImpl/EmailCodesImpl.kt +++ /dev/null @@ -1,53 +0,0 @@ -package subit.database.memoryImpl - -import kotlinx.coroutines.* -import kotlinx.datetime.Clock -import kotlinx.datetime.DateTimeUnit -import kotlinx.datetime.Instant -import kotlinx.datetime.plus -import subit.config.emailConfig -import subit.database.EmailCodes -import subit.logger.ForumLogger -import java.util.* - -class EmailCodesImpl: EmailCodes -{ - private val codes = Collections.synchronizedMap( - hashMapOf, Pair>() - ) - - init - { - val logger = ForumLogger.getLogger() - // 启动定期清理过期验证码任务 - @Suppress("OPT_IN_USAGE") - GlobalScope.launch(Dispatchers.IO) - { - while (true) - { - delay(1000/*ms*/*60/*s*/*5/*min*/) - logger.config("Clearing expired email codes") - logger.severe("Failed to clear expired email codes") { clearExpiredEmailCode() } - } - } - } - - private fun clearExpiredEmailCode() - { - codes.entries.removeIf { it.value.second < Clock.System.now() } - } - - override suspend fun addEmailCode(email: String, code: String, usage: EmailCodes.EmailCodeUsage) - { - codes[email to usage] = code to Clock.System.now().plus(emailConfig.codeValidTime, DateTimeUnit.SECOND) - } - - override suspend fun verifyEmailCode(email: String, code: String, usage: EmailCodes.EmailCodeUsage): Boolean - { - val pair = codes[email to usage] ?: return false - if (pair.first != code) return false - if (pair.second < Clock.System.now()) return false - codes.remove(email to usage) - return true - } -} \ No newline at end of file diff --git a/src/main/kotlin/subit/database/memoryImpl/MemoryDatabaseImpl.kt b/src/main/kotlin/subit/database/memoryImpl/MemoryDatabaseImpl.kt index f36d31a..16d645a 100644 --- a/src/main/kotlin/subit/database/memoryImpl/MemoryDatabaseImpl.kt +++ b/src/main/kotlin/subit/database/memoryImpl/MemoryDatabaseImpl.kt @@ -43,7 +43,6 @@ object MemoryDatabaseImpl: IDatabase, KoinComponent singleOf(::BannedWordsImpl).bind() singleOf(::BlocksImpl).bind() singleOf(::CommentsImpl).bind() - singleOf(::EmailCodesImpl).bind() singleOf(::LikesImpl).bind() singleOf(::NoticesImpl).bind() singleOf(::OperationsImpl).bind() @@ -54,7 +53,6 @@ object MemoryDatabaseImpl: IDatabase, KoinComponent singleOf(::ReportsImpl).bind() singleOf(::StarsImpl).bind() singleOf(::UsersImpl).bind() - singleOf(::WhitelistsImpl).bind() } getKoin().loadModules(listOf(module)) } diff --git a/src/main/kotlin/subit/database/memoryImpl/UsersImpl.kt b/src/main/kotlin/subit/database/memoryImpl/UsersImpl.kt index 9913904..89e482b 100644 --- a/src/main/kotlin/subit/database/memoryImpl/UsersImpl.kt +++ b/src/main/kotlin/subit/database/memoryImpl/UsersImpl.kt @@ -1,55 +1,16 @@ package subit.database.memoryImpl import subit.JWTAuth -import subit.dataClasses.PermissionLevel -import subit.dataClasses.Slice +import subit.dataClasses.* import subit.dataClasses.Slice.Companion.asSlice -import subit.dataClasses.UserFull -import subit.dataClasses.UserId import subit.dataClasses.UserId.Companion.toUserId import subit.database.Users import java.util.* class UsersImpl: Users { - private val map = Collections.synchronizedMap(hashMapOf()) - private val emailMap = Collections.synchronizedMap(hashMapOf()) - private val passwords = Collections.synchronizedMap(hashMapOf()) - override suspend fun createUser(username: String, password: String, email: String): UserId? - { - if (emailMap.containsKey(email)) return null - val id = (emailMap.size+1).toUserId() - emailMap[email] = id - map[id] = UserFull( - id = id, - username = username, - email = email, - introduction = null, - showStars = true, - permission = PermissionLevel.NORMAL, - filePermission = PermissionLevel.NORMAL, - registrationTime = System.currentTimeMillis() - ) - passwords[id] = JWTAuth.encryptPassword(password) - return id - } + private val map = Collections.synchronizedMap(hashMapOf()) - override suspend fun getEncryptedPassword(id: UserId): String? = passwords[id] - override suspend fun getEncryptedPassword(email: String): String? = emailMap[email]?.let { passwords[it] } - - override suspend fun getUser(id: UserId): UserFull? = map[id] - override suspend fun getUser(email: String): UserFull? = emailMap[email]?.let { map[it] } - override suspend fun setPassword(email: String, password: String): Boolean = - emailMap[email]?.let { id -> - passwords[id] = JWTAuth.encryptPassword(password) - true - } ?: false - - override suspend fun changeUsername(id: UserId, username: String): Boolean = - map[id]?.let { - map[id] = it.copy(username = username) - true - } ?: false override suspend fun changeIntroduction(id: UserId, introduction: String): Boolean = map[id]?.let { @@ -75,6 +36,17 @@ class UsersImpl: Users true } ?: false - override suspend fun searchUser(username: String, begin: Long, count: Int): Slice = - map.values.filter { it.username.contains(username) }.asSequence().asSlice(begin, count).map { it.id } + override suspend fun getOrCreateUser(id: UserId): DatabaseUser + { + if (id in map) return map[id]!! + val user = DatabaseUser( + id = id, + introduction = null, + showStars = false, + permission = PermissionLevel.NORMAL, + filePermission = PermissionLevel.NORMAL + ) + map[id] = user + return user + } } \ No newline at end of file diff --git a/src/main/kotlin/subit/database/memoryImpl/WhitelistsImpl.kt b/src/main/kotlin/subit/database/memoryImpl/WhitelistsImpl.kt deleted file mode 100644 index 6cc26e8..0000000 --- a/src/main/kotlin/subit/database/memoryImpl/WhitelistsImpl.kt +++ /dev/null @@ -1,20 +0,0 @@ -package subit.database.memoryImpl - -import subit.database.Whitelists -import java.util.Collections - -class WhitelistsImpl: Whitelists -{ - private val set = Collections.synchronizedSet(hashSetOf()) - - override suspend fun add(email: String) - { - set.add(email) - } - override suspend fun remove(email: String) - { - set.remove(email) - } - override suspend fun isWhitelisted(email: String): Boolean = set.contains(email) - override suspend fun getWhitelist(): List = set.toList() -} \ No newline at end of file diff --git a/src/main/kotlin/subit/database/sqlImpl/EmailCodesImpl.kt b/src/main/kotlin/subit/database/sqlImpl/EmailCodesImpl.kt deleted file mode 100644 index a1366c1..0000000 --- a/src/main/kotlin/subit/database/sqlImpl/EmailCodesImpl.kt +++ /dev/null @@ -1,83 +0,0 @@ -package subit.database.sqlImpl - -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.datetime.Clock -import kotlinx.datetime.DateTimeUnit -import kotlinx.datetime.plus -import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq -import org.jetbrains.exposed.sql.SqlExpressionBuilder.lessEq -import org.jetbrains.exposed.sql.Table -import org.jetbrains.exposed.sql.and -import org.jetbrains.exposed.sql.deleteWhere -import org.jetbrains.exposed.sql.insert -import org.jetbrains.exposed.sql.kotlin.datetime.CurrentTimestamp -import org.jetbrains.exposed.sql.kotlin.datetime.timestamp -import org.koin.core.component.KoinComponent -import subit.config.emailConfig -import subit.dataClasses.Slice.Companion.singleOrNull -import subit.database.EmailCodes -import subit.database.EmailCodes.EmailCodeUsage -import subit.logger.ForumLogger - -class EmailCodesImpl: DaoSqlImpl(EmailsTable), EmailCodes, KoinComponent -{ - object EmailsTable: Table("email_codes") - { - val email = varchar("email", 100).index() - val code = varchar("code", 10) - val time = timestamp("time").index() - val usage = enumerationByName("usage", 20) - } - - init - { - val logger = ForumLogger.getLogger() - // 启动定期清理过期验证码任务 - @Suppress("OPT_IN_USAGE") - GlobalScope.launch() - { - while (true) - { - logger.config("Clearing expired email codes") - logger.severe("Failed to clear expired email codes") { clearExpiredEmailCode() } - delay(1000/*ms*/*60/*s*/*5/*min*/) - } - } - } - - override suspend fun addEmailCode(email: String, code: String, usage: EmailCodeUsage): Unit = query() - { - insert { - it[EmailsTable.email] = email - it[EmailsTable.code] = code - it[EmailsTable.usage] = usage - it[time] = Clock.System.now().plus(emailConfig.codeValidTime, unit = DateTimeUnit.SECOND) - } - } - - /** - * 验证邮箱验证码,验证成功后将立即删除验证码 - */ - override suspend fun verifyEmailCode(email: String, code: String, usage: EmailCodeUsage): Boolean = query() - { - val result = select(time).where { - (EmailsTable.email eq email) and (EmailsTable.code eq code) and (EmailsTable.usage eq usage) - }.singleOrNull()?.let { it[time] } - - if (result != null) - { - EmailsTable.deleteWhere { - (EmailsTable.email eq email) and (EmailsTable.code eq code) and (EmailsTable.usage eq usage) - } - } - - result != null && result >= Clock.System.now() - } - - private suspend fun clearExpiredEmailCode(): Unit = query() - { - EmailsTable.deleteWhere { time lessEq CurrentTimestamp } - } -} \ No newline at end of file diff --git a/src/main/kotlin/subit/database/sqlImpl/SqlDatabaseImpl.kt b/src/main/kotlin/subit/database/sqlImpl/SqlDatabaseImpl.kt index a9426cf..382b02f 100644 --- a/src/main/kotlin/subit/database/sqlImpl/SqlDatabaseImpl.kt +++ b/src/main/kotlin/subit/database/sqlImpl/SqlDatabaseImpl.kt @@ -139,7 +139,6 @@ object SqlDatabaseImpl: IDatabase, KoinComponent singleOf(::BannedWordsImpl).bind() singleOf(::BlocksImpl).bind() singleOf(::CommentsImpl).bind() - singleOf(::EmailCodesImpl).bind() singleOf(::LikesImpl).bind() singleOf(::NoticesImpl).bind() singleOf(::OperationsImpl).bind() @@ -150,7 +149,6 @@ object SqlDatabaseImpl: IDatabase, KoinComponent singleOf(::ReportsImpl).bind() singleOf(::StarsImpl).bind() singleOf(::UsersImpl).bind() - singleOf(::WhitelistsImpl).bind() } getKoin().loadModules(listOf(module)) @@ -162,7 +160,6 @@ object SqlDatabaseImpl: IDatabase, KoinComponent (get() as DaoSqlImpl<*>).table (get() as DaoSqlImpl<*>).table (get() as DaoSqlImpl<*>).table - (get() as DaoSqlImpl<*>).table (get() as DaoSqlImpl<*>).table (get() as DaoSqlImpl<*>).table (get() as DaoSqlImpl<*>).table @@ -173,7 +170,6 @@ object SqlDatabaseImpl: IDatabase, KoinComponent (get() as DaoSqlImpl<*>).table (get() as DaoSqlImpl<*>).table (get() as DaoSqlImpl<*>).table - (get() as DaoSqlImpl<*>).table } } } diff --git a/src/main/kotlin/subit/database/sqlImpl/UsersImpl.kt b/src/main/kotlin/subit/database/sqlImpl/UsersImpl.kt index 995e422..054d711 100644 --- a/src/main/kotlin/subit/database/sqlImpl/UsersImpl.kt +++ b/src/main/kotlin/subit/database/sqlImpl/UsersImpl.kt @@ -1,18 +1,13 @@ package subit.database.sqlImpl import org.jetbrains.exposed.dao.id.IdTable -import org.jetbrains.exposed.sql.ResultRow -import org.jetbrains.exposed.sql.insertAndGetId +import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.kotlin.datetime.CurrentTimestamp import org.jetbrains.exposed.sql.kotlin.datetime.timestamp -import org.jetbrains.exposed.sql.selectAll -import org.jetbrains.exposed.sql.update import subit.JWTAuth -import subit.dataClasses.PermissionLevel -import subit.dataClasses.Slice +import subit.dataClasses.* import subit.dataClasses.Slice.Companion.asSlice -import subit.dataClasses.UserFull -import subit.dataClasses.UserId +import subit.dataClasses.Slice.Companion.single import subit.database.Users class UsersImpl: DaoSqlImpl(UserTable), Users @@ -22,11 +17,7 @@ class UsersImpl: DaoSqlImpl(UserTable), Users */ object UserTable: IdTable("users") { - override val id = userId("id").autoIncrement().entityId() - val username = varchar("username", 100).index() - val password = text("password") - val email = varchar("email", 100).uniqueIndex() - val registrationTime = timestamp("registration_time").defaultExpression(CurrentTimestamp) + override val id = userId("id").entityId() val introduction = text("introduction").nullable().default(null) val showStars = bool("show_stars").default(true) val permission = enumeration("permission").default(PermissionLevel.NORMAL) @@ -34,62 +25,14 @@ class UsersImpl: DaoSqlImpl(UserTable), Users override val primaryKey = PrimaryKey(id) } - private fun deserialize(row: ResultRow) = UserFull( + private fun deserialize(row: ResultRow) = DatabaseUser( id = row[UserTable.id].value, - username = row[UserTable.username], - email = row[UserTable.email], - registrationTime = row[UserTable.registrationTime].toEpochMilliseconds(), introduction = row[UserTable.introduction] ?: "", showStars = row[UserTable.showStars], permission = row[UserTable.permission], filePermission = row[UserTable.filePermission] ) - override suspend fun createUser( - username: String, - password: String, - email: String, - ): UserId? = query() - { - if (selectAll().where { UserTable.email eq email }.count() > 0) return@query null // 邮箱已存在 - val psw = JWTAuth.encryptPassword(password) // 加密密码 - insertAndGetId { - it[UserTable.username] = username - it[UserTable.password] = psw - it[UserTable.email] = email - }.value - } - - override suspend fun getEncryptedPassword(id: UserId): String? = query() - { - select(password).where { UserTable.id eq id }.singleOrNull()?.get(password) - } - override suspend fun getEncryptedPassword(email: String): String? = query() - { - select(password).where { UserTable.email eq email }.singleOrNull()?.get(password) - } - - override suspend fun getUser(id: UserId): UserFull? = query() - { - selectAll().where { UserTable.id eq id }.singleOrNull()?.let(::deserialize) - } - - override suspend fun getUser(email: String): UserFull? = query() - { - selectAll().where { UserTable.email eq email }.singleOrNull()?.let(::deserialize) - } - - override suspend fun setPassword(email: String, password: String): Boolean = query() - { - val psw = JWTAuth.encryptPassword(password) // 加密密码 - update({ UserTable.email eq email }) { it[UserTable.password] = psw } > 0 - } - - override suspend fun changeUsername(id: UserId, username: String): Boolean = query() - { - update({ UserTable.id eq id }) { it[UserTable.username] = username } > 0 - } - override suspend fun changeIntroduction(id: UserId, introduction: String): Boolean = query() { update({ UserTable.id eq id }) { it[UserTable.introduction] = introduction } > 0 @@ -110,8 +53,9 @@ class UsersImpl: DaoSqlImpl(UserTable), Users update({ UserTable.id eq id }) { it[filePermission] = permission } > 0 } - override suspend fun searchUser(username: String, begin: Long, count: Int): Slice = query() + override suspend fun getOrCreateUser(id: UserId): DatabaseUser = query() { - select(id).where { UserTable.username like "%$username%" }.asSlice(begin, count).map { it[id].value } + insertIgnore { it[UserTable.id] = id } + selectAll().where { UserTable.id eq id }.single().let(::deserialize) } } \ No newline at end of file diff --git a/src/main/kotlin/subit/database/sqlImpl/WhitelistsImpl.kt b/src/main/kotlin/subit/database/sqlImpl/WhitelistsImpl.kt deleted file mode 100644 index c724f64..0000000 --- a/src/main/kotlin/subit/database/sqlImpl/WhitelistsImpl.kt +++ /dev/null @@ -1,44 +0,0 @@ -package subit.database.sqlImpl - -import org.jetbrains.exposed.dao.id.IdTable -import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq -import org.jetbrains.exposed.sql.deleteWhere -import org.jetbrains.exposed.sql.insert -import org.jetbrains.exposed.sql.selectAll -import subit.database.Whitelists - -class WhitelistsImpl: DaoSqlImpl(WhitelistTable), Whitelists -{ - object WhitelistTable: IdTable("whitelist") - { - val email = varchar("email", 100).entityId() - override val id = email - override val primaryKey: PrimaryKey = PrimaryKey(email) - } - - override suspend fun add(email: String): Unit = query() - { - insert { - it[WhitelistTable.email] = email - } - } - - override suspend fun remove(email: String): Unit = query() - { - deleteWhere { - WhitelistTable.email eq email - } - } - - override suspend fun isWhitelisted(email: String): Boolean = query() - { - selectAll().where { - WhitelistTable.email eq email - }.count() > 0 - } - - override suspend fun getWhitelist(): List = query() - { - selectAll().map { it[email].value } - } -} \ No newline at end of file diff --git a/src/main/kotlin/subit/plugin/Authentication.kt b/src/main/kotlin/subit/plugin/Authentication.kt index bb12def..ab0dbc5 100644 --- a/src/main/kotlin/subit/plugin/Authentication.kt +++ b/src/main/kotlin/subit/plugin/Authentication.kt @@ -2,44 +2,14 @@ package subit.plugin import io.ktor.server.application.* import io.ktor.server.auth.* -import io.ktor.server.auth.jwt.* -import org.koin.ktor.ext.inject -import subit.JWTAuth -import subit.JWTAuth.initJwtAuth import subit.config.apiDocsConfig -import subit.dataClasses.UserId -import subit.database.Users -import subit.logger.ForumLogger +import subit.utils.SSO /** * 安装登陆验证服务 */ fun Application.installAuthentication() = install(Authentication) { - // 初始化jwt验证 - this@installAuthentication.initJwtAuth() - // jwt验证, 这个验证是用于论坛正常的用户登陆 - jwt("forum-auth") - { - verifier(JWTAuth.makeJwtVerifier()) // 设置验证器 - validate() // 设置验证函数 - { - val users: Users by inject() - ForumLogger.getLogger("ForumBackend.installAuthentication").config( - "用户token: id=${ - it.payload.getClaim("id") - .asInt() - }, password=${it.payload.getClaim("password").asString()}" - ) - if (!JWTAuth.checkLoginByEncryptedPassword( - it.payload.getClaim("id").asInt().let(::UserId), - it.payload.getClaim("password").asString() - ) - ) null - else users.getUser(it.payload.getClaim("id").asInt().let(::UserId)) - } - } - // 此登陆仅用于api文档的访问, 见ApiDocs插件 basic("auth-api-docs") { @@ -51,4 +21,9 @@ fun Application.installAuthentication() = install(Authentication) else null } } + + bearer("forum-auth") + { + authenticate { this.request.headers["Authorization"]?.let { SSO.getUserFull(it) } } + } } \ No newline at end of file diff --git a/src/main/kotlin/subit/plugin/RateLimit.kt b/src/main/kotlin/subit/plugin/RateLimit.kt index d650ef1..b00b953 100644 --- a/src/main/kotlin/subit/plugin/RateLimit.kt +++ b/src/main/kotlin/subit/plugin/RateLimit.kt @@ -5,7 +5,6 @@ import io.ktor.server.application.* import io.ktor.server.plugins.ratelimit.* import io.ktor.server.request.* import io.ktor.server.response.* -import subit.router.auth.EmailInfo import subit.router.posts.WarpPostId import subit.utils.HttpStatus import subit.utils.respond @@ -26,7 +25,7 @@ sealed interface RateLimit companion object { - val list = listOf(Search, Post, SendEmail, AddView) + val list = listOf(Search, Post, AddView) } data object Search: RateLimit @@ -65,23 +64,6 @@ sealed interface RateLimit call.parameters["Authorization"] ?: UUID.randomUUID() } - data object SendEmail: RateLimit - { - override val rawRateLimitName = "sendEmail" - override val limit = 1 - override val duration = 1.minutes - override suspend fun customResponse(call: ApplicationCall, duration: Duration) - { - call.respond(HttpStatus.TooManyRequests.copy(message = "发送邮件过于频繁, 请${duration}后再试")) - } - - /** - * 按照请求体中的邮箱及其用途来限制. 如果接收不到请求体的话应该会返回BadRequest, 所以这里通过随机UUID来不限制 - */ - override suspend fun getKey(call: ApplicationCall): Any = - runCatching { call.receive() }.getOrNull() ?: UUID.randomUUID() - } - data object AddView: RateLimit { override val rawRateLimitName = "addView" diff --git a/src/main/kotlin/subit/router/Admin.kt b/src/main/kotlin/subit/router/Admin.kt index 284c0b4..d4fcc50 100644 --- a/src/main/kotlin/subit/router/Admin.kt +++ b/src/main/kotlin/subit/router/Admin.kt @@ -10,10 +10,7 @@ import subit.JWTAuth.getLoginUser import subit.dataClasses.* import subit.database.* import subit.router.* -import subit.utils.HttpStatus -import subit.utils.checkUserInfo -import subit.utils.respond -import subit.utils.statuses +import subit.utils.* fun Route.admin() = route("/admin", { tags = listOf("用户管理") @@ -25,27 +22,6 @@ fun Route.admin() = route("/admin", { } }) { - post("/createUser", { - description = "创建用户, 需要超级管理员权限, 使用此接口创建用户无需邮箱验证码, 但需要邮箱为学校邮箱" - request { - body - { - required = true - description = "新用户信息" - example("example", CreateUser("username", "password", "email")) - } - } - response { - statuses( - HttpStatus.OK, - HttpStatus.EmailExist, - HttpStatus.EmailFormatError, - HttpStatus.UsernameFormatError, - HttpStatus.PasswordFormatError, - ) - } - }) { createUser() } - post("/prohibitUser", { description = "封禁用户, 需要当前用户的权限大于ADMIN且大于对方的权限" request { @@ -90,33 +66,6 @@ fun Route.admin() = route("/admin", { }) { changePermission() } } -@Serializable -private data class CreateUser(val username: String, val password: String, val email: String) - -private suspend fun Context.createUser() -{ - checkPermission { checkHasGlobalAdmin() } - - val users = get() - val operations = get() - val createUser = receiveAndCheckBody() - checkUserInfo(createUser.username, createUser.password, createUser.email).apply { - if (this != HttpStatus.OK) return call.respond(this) - } - users.createUser( - username = createUser.username, - password = createUser.password, - email = createUser.email, - ).apply { - return if (this == null) call.respond(HttpStatus.EmailExist) - else - { - operations.addOperation(getLoginUser()!!.id, createUser) - call.respond(HttpStatus.OK) - } - } -} - @Serializable private data class ProhibitUser(val id: UserId, val prohibit: Boolean, val time: Long, val reason: String) @@ -127,7 +76,7 @@ private suspend fun Context.prohibitUser() val operations = get() val loginUser = getLoginUser() ?: return call.respond(HttpStatus.Unauthorized) val prohibitUser = receiveAndCheckBody() - val user = users.getUser(prohibitUser.id) ?: return call.respond(HttpStatus.NotFound) + val user = SSO.getDbUser(prohibitUser.id) ?: return call.respond(HttpStatus.NotFound) if (loginUser.permission < PermissionLevel.ADMIN || loginUser.permission <= user.permission) return call.respond(HttpStatus.Forbidden) if (prohibitUser.prohibit) prohibits.addProhibit( @@ -158,7 +107,7 @@ private suspend fun Context.changePermission() val users = get() val loginUser = getLoginUser() ?: return call.respond(HttpStatus.Unauthorized) val changePermission = receiveAndCheckBody() - val user = users.getUser(changePermission.id) ?: return call.respond(HttpStatus.NotFound) + val user = SSO.getDbUser(changePermission.id) ?: return call.respond(HttpStatus.NotFound) if (loginUser.permission < PermissionLevel.ADMIN || loginUser.permission <= user.permission) return call.respond(HttpStatus.Forbidden) users.changePermission(changePermission.id, changePermission.permission) diff --git a/src/main/kotlin/subit/router/Auth.kt b/src/main/kotlin/subit/router/Auth.kt deleted file mode 100644 index d2eccb6..0000000 --- a/src/main/kotlin/subit/router/Auth.kt +++ /dev/null @@ -1,267 +0,0 @@ -@file:Suppress("PackageDirectoryMismatch") - -package subit.router.auth - -import io.github.smiley4.ktorswaggerui.dsl.routing.* -import io.ktor.server.application.* -import io.ktor.server.plugins.ratelimit.* -import io.ktor.server.routing.* -import kotlinx.serialization.Serializable -import subit.JWTAuth -import subit.JWTAuth.getLoginUser -import subit.config.emailConfig -import subit.dataClasses.UserId -import subit.database.* -import subit.plugin.RateLimit -import subit.router.Context -import subit.router.authenticated -import subit.router.example -import subit.router.get -import subit.utils.* - -fun Route.auth() = route("/auth", { - tags = listOf("账户") -}) -{ - post("/register", { - description = "注册, 若成功返回token" - request { - body - { - required = true - description = "注册信息" - example("example", RegisterInfo("username", "password", "email", "code")) - } - } - this.response { - statuses(HttpStatus.OK, example = JWTAuth.Token("token")) - statuses( - HttpStatus.WrongEmailCode, - HttpStatus.EmailExist, - HttpStatus.EmailFormatError, - HttpStatus.UsernameFormatError, - HttpStatus.PasswordFormatError, - HttpStatus.NotInWhitelist - ) - } - }) { register() } - - post("/login", { - description = "登陆, 若成功返回token" - request { - body() - { - required = true - description = "登陆信息, id(用户ID)和email(用户的邮箱)二选一" - example("example", LoginInfo(email = "email", password = "password", id = UserId(0))) - } - } - this.response { - statuses(HttpStatus.OK, example = JWTAuth.Token("token")) - statuses( - HttpStatus.PasswordError, - HttpStatus.AccountNotExist, - ) - } - }) { login() } - - post("/loginByCode", { - description = "通过邮箱验证码登陆, 若成功返回token" - request { - body() - { - required = true - description = "登陆信息, id(用户ID)和email(用户的邮箱)二选一" - example("example", LoginByCodeInfo(email = "email@abc.com", code = "123456")) - } - } - this.response { - statuses(HttpStatus.OK, example = JWTAuth.Token("token")) - statuses( - HttpStatus.AccountNotExist, - HttpStatus.WrongEmailCode, - ) - } - }) { loginByCode() } - - post("/resetPassword", { - description = "重置密码(忘记密码)" - request { - body - { - required = true - description = "重置密码信息" - example("example", ResetPasswordInfo("email@abc.com", "code", "newPassword")) - } - } - this.response { - statuses(HttpStatus.OK) - statuses( - HttpStatus.WrongEmailCode, - HttpStatus.AccountNotExist, - ) - } - }) { resetPassword() } - - rateLimit(RateLimit.SendEmail.rateLimitName) - { - post("/sendEmailCode", { - description = "发送邮箱验证码" - request { - body - { - required = true - description = "邮箱信息" - example("example", EmailInfo("email@abc.com", EmailCodes.EmailCodeUsage.REGISTER)) - } - } - this.response { - statuses(HttpStatus.OK) - statuses( - HttpStatus.EmailFormatError, - HttpStatus.TooManyRequests - ) - } - }) { sendEmailCode() } - } - - post("/changePassword", { - description = "修改密码" - request { - authenticated(true) - body - { - required = true - description = "修改密码信息" - example("example", ChangePasswordInfo("oldPassword", "newPassword")) - } - } - this.response { - statuses(HttpStatus.OK, example = JWTAuth.Token("token")) - statuses( - HttpStatus.Unauthorized, - HttpStatus.PasswordError, - HttpStatus.PasswordFormatError, - ) - } - }) { changePassword() } -} - -@Serializable -private data class RegisterInfo(val username: String, val password: String, val email: String, val code: String) - -private suspend fun Context.register() -{ - val registerInfo: RegisterInfo = receiveAndCheckBody() - // 检查用户名、密码、邮箱是否合法 - checkUserInfo(registerInfo.username, registerInfo.password, registerInfo.email).apply { - if (this != HttpStatus.OK) return call.respond(this) - } - if (emailConfig.enableWhiteList && !get().isWhitelisted(registerInfo.email)) - return call.respond(HttpStatus.NotInWhitelist) - // 验证邮箱验证码 - if (!get().verifyEmailCode( - registerInfo.email, - registerInfo.code, - EmailCodes.EmailCodeUsage.REGISTER - ) - ) return call.respond(HttpStatus.WrongEmailCode) - // 创建用户 - val id = get().createUser( - username = registerInfo.username, - password = registerInfo.password, - email = registerInfo.email, - ) ?: return call.respond(HttpStatus.EmailExist) - // 创建成功, 返回token - val token = JWTAuth.makeToken(id) ?: /*理论上不会进入此分支*/ return call.respond(HttpStatus.AccountNotExist) - return call.respond(HttpStatus.OK, token) -} - -@Serializable -private data class LoginInfo(val email: String? = null, val id: UserId? = null, val password: String) - -private suspend fun Context.login() -{ - val users = get() - val loginInfo = receiveAndCheckBody() - val checked = if (loginInfo.id != null) JWTAuth.checkLogin(loginInfo.id, loginInfo.password) - else if (loginInfo.email != null) JWTAuth.checkLogin(loginInfo.email, loginInfo.password) - else return call.respond(HttpStatus.BadRequest) - // 若登陆失败,返回错误信息 - if (!checked) return call.respond(HttpStatus.PasswordError) - val id = loginInfo.id - ?: users.getUser(loginInfo.email!!)?.id - ?: /*理论上不会进入此分支*/ return call.respond(HttpStatus.AccountNotExist) - val token = JWTAuth.makeToken(id) ?: /*理论上不会进入此分支*/ return call.respond(HttpStatus.AccountNotExist) - return call.respond(HttpStatus.OK, token) -} - -@Serializable -private data class LoginByCodeInfo(val email: String? = null, val id: UserId? = null, val code: String) - -private suspend fun Context.loginByCode() -{ - val loginInfo = receiveAndCheckBody() - val email = - loginInfo.email ?: // 若email不为空,直接使用email - loginInfo.id?.let { - get().getUser(it)?.email // email为空,尝试从id获取email - ?: return call.respond(HttpStatus.AccountNotExist) - } // 若id不存在,返回登陆失败 - ?: return call.respond(HttpStatus.BadRequest) // id和email都为空,返回错误的请求 - if (!get().verifyEmailCode(email, loginInfo.code, EmailCodes.EmailCodeUsage.LOGIN)) - return call.respond(HttpStatus.WrongEmailCode) - val user = get().getUser(email) ?: return call.respond(HttpStatus.AccountNotExist) - val token = JWTAuth.makeToken(user.id) ?: /*理论上不会进入此分支*/ return call.respond(HttpStatus.AccountNotExist) - return call.respond(HttpStatus.OK, token) -} - -@Serializable -private data class ResetPasswordInfo(val email: String, val code: String, val password: String) - -private suspend fun Context.resetPassword() -{ - // 接收重置密码的信息 - val resetPasswordInfo = receiveAndCheckBody() - // 验证邮箱验证码 - if (!get().verifyEmailCode( - resetPasswordInfo.email, - resetPasswordInfo.code, - EmailCodes.EmailCodeUsage.RESET_PASSWORD - ) - ) return call.respond(HttpStatus.WrongEmailCode) - // 重置密码 - if (get().setPassword(resetPasswordInfo.email, resetPasswordInfo.password)) - call.respond(HttpStatus.OK) - else - call.respond(HttpStatus.AccountNotExist) -} - -@Serializable -private data class ChangePasswordInfo(val oldPassword: String, val newPassword: String) - -private suspend fun Context.changePassword() -{ - val users = get() - - val (oldPassword, newPassword) = receiveAndCheckBody() - val user = getLoginUser() ?: return call.respond(HttpStatus.Unauthorized) - if (!JWTAuth.checkLogin(user.id, oldPassword)) return call.respond(HttpStatus.PasswordError) - if (!checkPassword(newPassword)) return call.respond(HttpStatus.PasswordFormatError) - users.setPassword(user.email, newPassword) - val token = JWTAuth.makeToken(user.id) ?: /*理论上不会进入此分支*/ return call.respond(HttpStatus.AccountNotExist) - return call.respond(HttpStatus.OK, token) -} - -@Serializable -data class EmailInfo(val email: String, val usage: EmailCodes.EmailCodeUsage) - -private suspend fun Context.sendEmailCode() -{ - val emailInfo = receiveAndCheckBody() - if (!checkEmail(emailInfo.email)) - return call.respond(HttpStatus.EmailFormatError) - val emailCodes = get() - emailCodes.sendEmailCode(emailInfo.email, emailInfo.usage) - call.respond(HttpStatus.OK) -} \ No newline at end of file diff --git a/src/main/kotlin/subit/router/Files.kt b/src/main/kotlin/subit/router/Files.kt index f31a5de..2b7a2cd 100644 --- a/src/main/kotlin/subit/router/Files.kt +++ b/src/main/kotlin/subit/router/Files.kt @@ -233,7 +233,8 @@ private suspend fun Context.uploadFile() } } if (fileInfo == null || input == null) return call.respond(HttpStatus.BadRequest) - if (size == null || user.getSpaceInfo().canUpload(size!!)) return call.respond(HttpStatus.NotEnoughSpace) + if (size == null || user.toDatabaseUser().getSpaceInfo().canUpload(size!!)) + return call.respond(HttpStatus.NotEnoughSpace) FileUtils.saveFile( input = input!!, fileName = fileInfo!!.fileName, @@ -255,11 +256,11 @@ private suspend fun Context.getFileList() if (user != null && (user.id == id || id == UserId(0) || user.permission >= PermissionLevel.ADMIN)) { val files = user.id.getUserFiles().map { it.first.toString() } - val info = user.getSpaceInfo() + val info = user.toDatabaseUser().getSpaceInfo() return call.respond(HttpStatus.OK, Files(info, files.asSlice(begin, count))) } val file = id.getUserFiles().filter { user.canGet(it.second) }.map { it.first.toString() } - val info = get().getUser(id)?.getSpaceInfo() ?: return call.respond(HttpStatus.NotFound) + val info = SSO.getDbUser(id)?.getSpaceInfo() ?: return call.respond(HttpStatus.NotFound) call.respond(HttpStatus.OK, Files(info, file.asSlice(begin, count))) } @@ -281,16 +282,16 @@ private suspend fun Context.changePublic() } @Serializable -private data class ChangePermission(val id: UserId, val permission: PermissionLevel) +private data class ChangePermission(val id: UserId, val filePermission: PermissionLevel) private suspend fun Context.changePermission() { val loginUser = getLoginUser() ?: return call.respond(HttpStatus.Unauthorized) val changePermission = receiveAndCheckBody() - val user = get().getUser(changePermission.id) ?: return call.respond(HttpStatus.NotFound) - if (loginUser.permission < PermissionLevel.ADMIN || loginUser.permission <= user.permission) + val user = SSO.getDbUser(changePermission.id) ?: return call.respond(HttpStatus.NotFound) + if (loginUser.filePermission < PermissionLevel.ADMIN || loginUser.filePermission <= user.filePermission) return call.respond(HttpStatus.Forbidden) - get().changePermission(changePermission.id, changePermission.permission) + get().changeFilePermission(changePermission.id, changePermission.filePermission) get().addOperation(loginUser.id, changePermission) call.respond(HttpStatus.OK) } \ No newline at end of file diff --git a/src/main/kotlin/subit/router/Home.kt b/src/main/kotlin/subit/router/Home.kt index af99139..ba8fce6 100644 --- a/src/main/kotlin/subit/router/Home.kt +++ b/src/main/kotlin/subit/router/Home.kt @@ -59,12 +59,6 @@ fun Route.home() = route("/home", { } }) { - get("/user", { - description = "搜索用户 会返回所有用户名包含key的用户" - response { - statuses>(HttpStatus.OK, example = sliceOf(UserId(0))) - } - }) { searchUser() } get("/block", { description = "搜索板块, 会返回板块名称或介绍包含关键词的板块" @@ -119,13 +113,6 @@ private suspend fun Context.getHotPosts() call.respond(HttpStatusCode.OK, result) } -private suspend fun Context.searchUser() -{ - val username = call.parameters["key"] ?: return call.respond(HttpStatus.BadRequest) - val (begin, count) = call.getPage() - call.respond(HttpStatus.OK, get().searchUser(username, begin, count)) -} - private suspend fun Context.searchBlock() { val key = call.parameters["key"] ?: return call.respond(HttpStatus.BadRequest) diff --git a/src/main/kotlin/subit/router/Router.kt b/src/main/kotlin/subit/router/Router.kt index 5f36652..85a8be3 100644 --- a/src/main/kotlin/subit/router/Router.kt +++ b/src/main/kotlin/subit/router/Router.kt @@ -13,7 +13,6 @@ import subit.config.systemConfig import subit.database.Prohibits import subit.database.checkParameters import subit.router.admin.admin -import subit.router.auth.auth import subit.router.bannedWords.bannedWords import subit.router.block.block import subit.router.comment.comment @@ -72,7 +71,6 @@ fun Application.router() = routing() } admin() - auth() bannedWords() block() comment() diff --git a/src/main/kotlin/subit/router/User.kt b/src/main/kotlin/subit/router/User.kt index c39cca7..097c063 100644 --- a/src/main/kotlin/subit/router/User.kt +++ b/src/main/kotlin/subit/router/User.kt @@ -17,10 +17,7 @@ import subit.dataClasses.UserId.Companion.toUserIdOrNull import subit.database.* import subit.logger.ForumLogger import subit.router.* -import subit.utils.AvatarUtils -import subit.utils.HttpStatus -import subit.utils.respond -import subit.utils.statuses +import subit.utils.* import java.io.ByteArrayOutputStream import java.io.File import javax.imageio.ImageIO @@ -83,77 +80,6 @@ fun Route.user() = route("/user", { } }) { changeIntroduction() } - post("/avatar/{id}", { - description = "修改头像, 修改他人头像要求user权限在ADMIN以上" - request { - authenticated(true) - pathParameter("id") - { - required = true - description = "要修改的用户ID, 0为当前登陆用户" - } - body() - { - required = true - mediaTypes(ContentType.Image.Any) - description = "头像图片, 要求是正方形的" - } - } - response { - statuses( - HttpStatus.OK, - HttpStatus.NotFound, - HttpStatus.Forbidden, - HttpStatus.Unauthorized, - HttpStatus.PayloadTooLarge, - HttpStatus.UnsupportedMediaType - ) - } - }) { changeAvatar() } - - get("/avatar/{id}", { - description = "获取头像" - request { - authenticated(false) - pathParameter("id") - { - required = true - description = "要获取的用户ID, 0为当前登陆用户, 若id不为0则无需登陆, 否则需要登陆" - } - } - response { - statuses(HttpStatus.BadRequest, HttpStatus.Unauthorized) - HttpStatus.OK.code to { - description = "获取头像成功" - body() - { - description = "获取到的头像, 总是png格式的" - mediaTypes(ContentType.Image.PNG) - } - } - } - }) { getAvatar() } - - delete("/avatar/{id}", { - description = "删除头像, 即恢复默认头像, 删除他人头像要求user权限在ADMIN以上" - request { - authenticated(true) - pathParameter("id") - { - required = true - description = "要删除的用户ID, 0为当前登陆用户" - } - } - response { - statuses( - HttpStatus.OK, - HttpStatus.NotFound, - HttpStatus.Forbidden, - HttpStatus.Unauthorized, - ) - } - }) { deleteAvatar() } - get("/stars/{id}", { description = "获取用户收藏的帖子" request { @@ -205,11 +131,10 @@ private suspend fun Context.getUserInfo() } else { - val user = get().getUser(id) ?: return call.respond(HttpStatus.NotFound) - if (loginUser != null && loginUser.permission >= PermissionLevel.ADMIN) - call.respond(HttpStatus.OK, user) - else - call.respond(HttpStatus.OK, user.toBasicUserInfo()) + val user = SSO.getBasicUserInfo(id) ?: return call.respond(HttpStatus.NotFound) + // 这里需要判断类型并转换再返回, 因为respond的返回体类型是编译时确定的 + if (user is UserFull) return call.respond(HttpStatus.OK, user as UserFull) + return call.respond(HttpStatus.OK, user as BasicUserInfo) } } @@ -239,67 +164,6 @@ private suspend fun Context.changeIntroduction() } } -private suspend fun Context.changeAvatar() -{ - val id = call.parameters["id"]?.toUserIdOrNull() ?: return call.respond(HttpStatus.BadRequest) - val loginUser = getLoginUser() ?: return call.respond(HttpStatus.Unauthorized) - // 检查body大小 - val size = call.request.headers["Content-Length"]?.toLongOrNull() ?: return call.respond(HttpStatus.BadRequest) - // 若图片大于10MB( 10 << 20 ), 返回请求实体过大 - if (size >= 10 shl 20) return call.respond(HttpStatus.PayloadTooLarge) - val image = runCatching() - { - withContext(Dispatchers.IO) - { - ImageIO.read(call.receiveStream()) - } - }.getOrNull() ?: return call.respond(HttpStatus.UnsupportedMediaType) - if (id == UserId(0) && loginUser.permission >= PermissionLevel.NORMAL) - { - AvatarUtils.setAvatar(loginUser.id, image) - } - else - { - checkPermission { checkHasGlobalAdmin() } - val user = get().getUser(id) ?: return call.respond(HttpStatus.NotFound) - AvatarUtils.setAvatar(user.id, image) - } - call.respond(HttpStatus.OK) -} - -private suspend fun Context.deleteAvatar() -{ - val id = call.parameters["id"]?.toUserIdOrNull() ?: return call.respond(HttpStatus.BadRequest) - val loginUser = getLoginUser() ?: return call.respond(HttpStatus.Unauthorized) - if (id == UserId(0)) - { - AvatarUtils.setDefaultAvatar(loginUser.id) - call.respond(HttpStatus.OK) - } - else - { - checkPermission { checkHasGlobalAdmin() } - val user = get().getUser(id) ?: return call.respond(HttpStatus.NotFound) - AvatarUtils.setDefaultAvatar(user.id) - call.respond(HttpStatus.OK) - } -} - -private suspend fun Context.getAvatar() -{ - val id = (call.parameters["id"]?.toUserIdOrNull() ?: return call.respond(HttpStatus.BadRequest)).let { - if (it == UserId(0)) getLoginUser()?.id ?: return call.respond(HttpStatus.Unauthorized) - else it - } - val avatar = AvatarUtils.getAvatar(id) - call.respondBytes(ContentType.Image.Any, HttpStatusCode.OK) - { - val output = ByteArrayOutputStream() - ImageIO.write(avatar, "png", output) - output.toByteArray() - } -} - private suspend fun Context.getStars() { val id = call.parameters["id"]?.toUserIdOrNull() ?: return call.respond(HttpStatus.BadRequest) @@ -314,7 +178,7 @@ private suspend fun Context.getStars() return call.respond(HttpStatus.OK, stars) } // 查询其他用户的收藏 - val user = get().getUser(id) ?: return call.respond(HttpStatus.NotFound) + val user = SSO.getDbUser(id) ?: return call.respond(HttpStatus.NotFound) // 若对方不展示收藏, 而当前用户未登录或不是管理员, 返回Forbidden if (!user.showStars && (loginUser == null || loginUser.permission < PermissionLevel.ADMIN)) return call.respond(HttpStatus.Forbidden) diff --git a/src/main/kotlin/subit/utils/FileUtils.kt b/src/main/kotlin/subit/utils/FileUtils.kt index 3b3eadc..8e068b7 100644 --- a/src/main/kotlin/subit/utils/FileUtils.kt +++ b/src/main/kotlin/subit/utils/FileUtils.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import subit.config.filesConfig +import subit.dataClasses.DatabaseUser import subit.dataClasses.PermissionLevel import subit.dataClasses.UserFull import subit.dataClasses.UserId @@ -156,7 +157,7 @@ object FileUtils /** * 获取使用空间与剩余空间 */ - suspend fun UserFull.getSpaceInfo(): SpaceInfo = withContext(Dispatchers.IO) + suspend fun DatabaseUser.getSpaceInfo(): SpaceInfo = withContext(Dispatchers.IO) { val userFolder = File(rawFolder, this@getSpaceInfo.id.value.toString(16)) val max = if (this@getSpaceInfo.filePermission >= PermissionLevel.ADMIN) filesConfig.adminMaxFileSize @@ -188,67 +189,4 @@ object FileUtils val indexFile = File(indexFolder, "${id}.index") indexFile.writeText(fileInfoSerializer.encodeToString(FileInfo.serializer(), info)) } -} - -/** - * 头像工具类 - * 头像存储在本地, 按照用户ID给每个用户创建一个文件夹, 文件夹中存放用户的头像 - * 头像文件名为数字, 从0开始, 依次递增, 数字最大的即为当前使用的头像 - * 默认头像存放在 default 文件夹中, 可以在其中添加任意数量的头像, 用户被设置为默认头像时, 会随机选择一个头像 - */ -object AvatarUtils -{ - private val logger = ForumLogger.getLogger() - private val avatarFolder = File(FileUtils.dataFolder, "/avatars") - private val defaultAvatarFolder = File(avatarFolder, "default") - - init - { - avatarFolder.mkdirs() - } - - fun setAvatar(user: UserId, avatar: BufferedImage) - { - val userAvatarFolder = File(avatarFolder, user.value.toString(16).padStart(16, '0')) - userAvatarFolder.mkdirs() - // 文件夹中已有的头像数量 - val avatarCount = userAvatarFolder.listFiles()?.size ?: 0 - val avatarFile = File(userAvatarFolder, "${avatarCount}.png") - avatarFile.createNewFile() - // 将头像大小调整为 1024x1024 - val resizedAvatar = BufferedImage(1024, 1024, BufferedImage.TYPE_INT_ARGB) - val graphics = resizedAvatar.createGraphics() - graphics.drawImage(avatar, 0, 0, 1024, 1024, null) - graphics.dispose() - // 保存头像 - ImageIO.write(resizedAvatar, "png", avatarFile) - } - - fun setDefaultAvatar(user: UserId): BufferedImage - { - val userAvatarFolder = File(avatarFolder, user.value.toString(16).padStart(16, '0')) - userAvatarFolder.mkdirs() - // 文件夹中已有的头像数量 - val avatarCount = userAvatarFolder.listFiles()?.size ?: 0 - val avatarFile = File(userAvatarFolder, "${avatarCount}.png") - // 在默认头像文件夹中随机选择一个头像 - val defaultAvatarFiles = defaultAvatarFolder.listFiles() - val defaultAvatar = defaultAvatarFiles?.randomOrNull() - if (defaultAvatar == null) - { - logger.warning("No default avatar found") - return BufferedImage(1024, 1024, BufferedImage.TYPE_INT_ARGB) - } - // 保存头像 - defaultAvatar.copyTo(avatarFile) - return ImageIO.read(defaultAvatar) - } - - fun getAvatar(user: UserId): BufferedImage - { - val userAvatarFolder = File(avatarFolder, user.value.toString(16).padStart(16, '0')) - val avatarCount = userAvatarFolder.listFiles()?.size ?: 0 - val avatarFile = File(userAvatarFolder, "${avatarCount-1}.png") - return if (avatarFile.exists()) ImageIO.read(avatarFile) else setDefaultAvatar(user) - } } \ No newline at end of file diff --git a/src/main/kotlin/subit/utils/SSO.kt b/src/main/kotlin/subit/utils/SSO.kt new file mode 100644 index 0000000..b2905cf --- /dev/null +++ b/src/main/kotlin/subit/utils/SSO.kt @@ -0,0 +1,79 @@ +package subit.utils + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import subit.config.systemConfig +import subit.dataClasses.* +import subit.database.Users +import java.net.HttpURLConnection +import java.net.URL + +object SSO: KoinComponent +{ + val users: Users by inject() + val json = Json { + ignoreUnknownKeys = true + isLenient = true + allowStructuredMapKeys = true + encodeDefaults = true + } + + private fun decodeSsoUser(response: String): SsoUser? = + runCatching { json.decodeFromString(SsoUserFull.serializer(), response) } + .getOrElse { + runCatching { json.decodeFromString(SsoUserInfo.serializer(), response) } + .getOrNull() + } + + suspend fun getUser(userId: UserId): SsoUser? = withContext(Dispatchers.IO) + { + val url = URL(systemConfig.ssoServer + "/info/${userId.value}") + val connection = url.openConnection() as HttpURLConnection + connection.requestMethod = "GET" + connection.connect() + val response = + runCatching { connection.inputStream.bufferedReader().readText() }.getOrNull() ?: return@withContext null + connection.disconnect() + return@withContext decodeSsoUser(response) + } + + suspend fun getUser(token: String): SsoUserFull? = withContext(Dispatchers.IO) + { + val url = URL(systemConfig.ssoServer + "/info/0") + val connection = url.openConnection() as HttpURLConnection + connection.setRequestProperty("Authorization", token) + connection.requestMethod = "GET" + connection.connect() + val response = + runCatching { connection.inputStream.bufferedReader().readText() }.getOrNull() ?: return@withContext null + connection.disconnect() + return@withContext json.decodeFromString(SsoUserFull.serializer(), response) + } + + suspend fun getUserFull(token: String): UserFull? + { + val ssoUser = getUser(token) ?: return null + val dbUser = users.getOrCreateUser(ssoUser.id) + return UserFull.from(ssoUser, dbUser) + } + + /** + * 相比[Users.getOrCreateUser],该方法会验证[userId]在sso中存在, 但前者不论sso中是否存在都会创建用户 + */ + suspend fun getDbUser(userId: UserId): DatabaseUser? + { + val ssoUser = getUser(userId) ?: return null + return users.getOrCreateUser(ssoUser.id) + } + + suspend fun getBasicUserInfo(userId: UserId): UserInfo? + { + val ssoUser = getUser(userId) ?: return null + val dbUser = users.getOrCreateUser(ssoUser.id) + return if (ssoUser is SsoUserFull) UserFull.from(ssoUser, dbUser) + else BasicUserInfo.from(ssoUser, dbUser) + } +} \ No newline at end of file diff --git a/src/main/kotlin/subit/utils/Utils.kt b/src/main/kotlin/subit/utils/Utils.kt index 66d9145..dac2e63 100644 --- a/src/main/kotlin/subit/utils/Utils.kt +++ b/src/main/kotlin/subit/utils/Utils.kt @@ -1,23 +1,6 @@ package subit.utils -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import subit.config.emailConfig -import subit.database.EmailCodes import java.util.* -import javax.mail.Address -import javax.mail.Message -import javax.mail.Session -import javax.mail.internet.InternetAddress -import javax.mail.internet.MimeBodyPart -import javax.mail.internet.MimeMessage -import javax.mail.internet.MimeMultipart - -/** - * 检查邮箱格式是否正确 - */ -fun checkEmail(email: String): Boolean = emailConfig.pattern.matcher(email).matches() /** * 检查密码是否合法 @@ -35,43 +18,4 @@ fun checkUsername(username: String): Boolean = username.length in 2..20 && username.all { it in '\u4e00'..'\u9fa5' || it.isLetterOrDigit() || it in "_-." } -fun checkUserInfo(username: String, password: String, email: String): HttpStatus -{ - if (!checkEmail(email)) return HttpStatus.EmailFormatError - if (!checkPassword(password)) return HttpStatus.PasswordFormatError - if (!checkUsername(username)) return HttpStatus.UsernameFormatError - return HttpStatus.OK -} - -fun String?.toUUIDOrNull(): UUID? = runCatching { UUID.fromString(this) }.getOrNull() - -private val sendEmailScope = CoroutineScope(Dispatchers.IO) - -fun sendEmail(email: String, code: String, usage: EmailCodes.EmailCodeUsage) = sendEmailScope.async() -{ - val props = Properties() - props.setProperty("mail.smtp.auth", "true") - props.setProperty("mail.host", emailConfig.host) - props.setProperty("mail.port", emailConfig.port.toString()) - props.setProperty("mail.smtp.starttls.enable", "true") - val session = Session.getInstance(props) - val message = MimeMessage(session) - message.setFrom(InternetAddress(emailConfig.sender)) - message.setRecipient(Message.RecipientType.TO, InternetAddress(email)) - message.subject = emailConfig.verifyEmailTitle - val multipart = MimeMultipart() - val bodyPart = MimeBodyPart() - bodyPart.setText( - """ - 您的验证码为: $code - 有效期为: ${emailConfig.codeValidTime}秒 - 此验证码仅用于论坛${usage.description},请勿泄露给他人。若非本人操作,请忽略此邮件。 - """.trimIndent() - ) - multipart.addBodyPart(bodyPart) - message.setContent(multipart) - val transport = session.getTransport("smtp") - transport.connect(emailConfig.host, emailConfig.sender, emailConfig.password) - transport.sendMessage(message, arrayOf
(InternetAddress(email))) - transport.close() -} \ No newline at end of file +fun String?.toUUIDOrNull(): UUID? = runCatching { UUID.fromString(this) }.getOrNull() \ No newline at end of file