tag(功能):完善登录功能

This commit is contained in:
OrangeRoll
2026-08-07 14:07:42 +08:00
parent 7f76df0002
commit 9c0a4f59f6
15 changed files with 966 additions and 244 deletions
@@ -1,11 +1,13 @@
package top.OrangeRoll
import configureRouting
import io.ktor.server.application.Application
/**
* Ktor's YAML module entry point.
*/
fun Application.module() {
configureDatabase()
bootstrapAdministrator()
configureSecurity()
configureRouting()
}
@@ -0,0 +1,22 @@
package top.OrangeRoll
import io.ktor.server.application.Application
/**
* Creates the first administrator only when it is absent.
* Keep these values in ignored application.local.yaml or environment variables.
*/
fun Application.bootstrapAdministrator() {
val config = environment.config
val username = config.propertyOrNull("bootstrapAdmin.username")?.getString()
?: System.getenv("BOOTSTRAP_ADMIN_USERNAME")
?: return
val password = config.propertyOrNull("bootstrapAdmin.password")?.getString()
?: System.getenv("BOOTSTRAP_ADMIN_PASSWORD")
?: return
val displayName = config.propertyOrNull("bootstrapAdmin.displayName")?.getString()
?: System.getenv("BOOTSTRAP_ADMIN_DISPLAY_NAME")
?: username
UserRepository.bootstrapAdmin(username, password, displayName)
}
@@ -0,0 +1,51 @@
package top.OrangeRoll
import io.ktor.server.application.Application
import io.ktor.server.application.log
import org.flywaydb.core.Flyway
import java.sql.Connection
import java.sql.DriverManager
/**
* Applies versioned SQL migrations before the API starts accepting requests.
*
* Configuration comes from application.local.yaml for local development or
* DATABASE_* environment variables when the API runs in Docker Compose.
*/
fun Application.configureDatabase() {
val config = environment.config
val jdbcUrl = config.propertyOrNull("database.jdbcUrl")?.getString()
?: System.getenv("DATABASE_JDBC_URL")
val user = config.propertyOrNull("database.user")?.getString()
?: System.getenv("DATABASE_USER")
val password = config.propertyOrNull("database.password")?.getString()
?: System.getenv("DATABASE_PASSWORD")
require(!jdbcUrl.isNullOrBlank()) { "Database JDBC URL is not configured" }
require(!user.isNullOrBlank()) { "Database user is not configured" }
require(password != null) { "Database password is not configured" }
val result = Flyway.configure()
.dataSource(jdbcUrl, user, password)
.locations("classpath:db/migration")
.load()
.migrate()
log.info("Database migration complete: ${result.migrationsExecuted} migration(s) applied")
Database.configure(jdbcUrl, user, password)
}
object Database {
private lateinit var jdbcUrl: String
private lateinit var user: String
private lateinit var password: String
fun configure(jdbcUrl: String, user: String, password: String) {
this.jdbcUrl = jdbcUrl
this.user = user
this.password = password
}
fun <T> useConnection(block: (Connection) -> T): T =
DriverManager.getConnection(jdbcUrl, user, password).use(block)
}
@@ -0,0 +1,269 @@
package top.OrangeRoll
import java.security.MessageDigest
import java.security.SecureRandom
import java.sql.Connection
import java.time.Instant
import java.util.Base64
import java.util.UUID
import org.mindrot.jbcrypt.BCrypt
data class User(
val id: UUID,
val username: String,
val passwordHash: String,
val displayName: String,
val isAdmin: Boolean,
)
data class RoomRecord(
val id: UUID,
val title: String,
val status: String,
val createdBy: UUID,
)
data class RoomMember(val roomId: UUID, val userId: UUID, val role: RoomRole)
enum class InvitationType { user, admin }
object UserRepository {
fun findByUsername(username: String): User? = Database.useConnection { connection ->
connection.prepareStatement(
"SELECT id, username, password_hash, display_name, is_admin FROM users WHERE username = ?",
).use { statement ->
statement.setString(1, username)
statement.executeQuery().use { result ->
if (result.next()) result.toUser() else null
}
}
}
fun findById(id: UUID): User? = Database.useConnection { connection ->
connection.prepareStatement(
"SELECT id, username, password_hash, display_name, is_admin FROM users WHERE id = ?",
).use { statement ->
statement.setObject(1, id)
statement.executeQuery().use { result ->
if (result.next()) result.toUser() else null
}
}
}
fun bootstrapAdmin(username: String, password: String, displayName: String) {
if (findByUsername(username) != null) return
Database.useConnection { connection ->
connection.prepareStatement(
"INSERT INTO users (id, username, password_hash, display_name, is_admin) VALUES (?, ?, ?, ?, TRUE)",
).use { statement ->
statement.setObject(1, UUID.randomUUID())
statement.setString(2, username)
statement.setString(3, BCrypt.hashpw(password, BCrypt.gensalt()))
statement.setString(4, displayName)
statement.executeUpdate()
}
}
}
fun register(inviteCode: String, username: String, password: String, displayName: String): User =
Database.useConnection { connection ->
connection.autoCommit = false
try {
val invitation = connection.prepareStatement(
"""
SELECT id, invitation_type, expires_at, used_at
FROM invitations
WHERE code_hash = ?
FOR UPDATE
""".trimIndent(),
).use { statement ->
statement.setString(1, hashInviteCode(inviteCode))
statement.executeQuery().use { result ->
if (!result.next()) throw ApiException("邀请码无效", 400)
InviteRow(
id = result.getObject("id", UUID::class.java),
type = InvitationType.valueOf(result.getString("invitation_type")),
expiresAt = result.getTimestamp("expires_at").toInstant(),
usedAt = result.getTimestamp("used_at")?.toInstant(),
)
}
}
if (invitation.usedAt != null || invitation.expiresAt.isBefore(Instant.now())) {
throw ApiException("邀请码已使用或已过期", 400)
}
val user = User(
id = UUID.randomUUID(),
username = username,
passwordHash = BCrypt.hashpw(password, BCrypt.gensalt()),
displayName = displayName,
isAdmin = invitation.type == InvitationType.admin,
)
connection.prepareStatement(
"INSERT INTO users (id, username, password_hash, display_name, is_admin) VALUES (?, ?, ?, ?, ?)",
).use { statement ->
statement.setObject(1, user.id)
statement.setString(2, user.username)
statement.setString(3, user.passwordHash)
statement.setString(4, user.displayName)
statement.setBoolean(5, user.isAdmin)
statement.executeUpdate()
}
connection.prepareStatement(
"UPDATE invitations SET used_by = ?, used_at = CURRENT_TIMESTAMP WHERE id = ?",
).use { statement ->
statement.setObject(1, user.id)
statement.setObject(2, invitation.id)
statement.executeUpdate()
}
connection.commit()
user
} catch (error: Throwable) {
connection.rollback()
throw error
} finally {
connection.autoCommit = true
}
}
private data class InviteRow(
val id: UUID,
val type: InvitationType,
val expiresAt: Instant,
val usedAt: Instant?,
)
}
object InvitationRepository {
fun create(createdBy: UUID, type: InvitationType, expiresAt: Instant): String {
val code = Base64.getUrlEncoder().withoutPadding()
.encodeToString(SecureRandom().generateSeed(24))
Database.useConnection { connection ->
connection.prepareStatement(
"""
INSERT INTO invitations (id, code_hash, invitation_type, created_by, expires_at)
VALUES (?, ?, ?, ?, ?)
""".trimIndent(),
).use { statement ->
statement.setObject(1, UUID.randomUUID())
statement.setString(2, hashInviteCode(code))
statement.setString(3, type.name)
statement.setObject(4, createdBy)
statement.setTimestamp(5, java.sql.Timestamp.from(expiresAt))
statement.executeUpdate()
}
}
return code
}
}
object RoomRepository {
fun create(hostId: UUID, title: String): RoomRecord {
val room = RoomRecord(UUID.randomUUID(), title, "open", hostId)
Database.useConnection { connection ->
connection.autoCommit = false
try {
connection.prepareStatement(
"INSERT INTO rooms (id, slug, title, created_by) VALUES (?, ?, ?, ?)",
).use { statement ->
statement.setObject(1, room.id)
statement.setString(2, room.id.toString())
statement.setString(3, room.title)
statement.setObject(4, hostId)
statement.executeUpdate()
}
connection.prepareStatement(
"INSERT INTO room_members (room_id, user_id, role) VALUES (?, ?, 'host')",
).use { statement ->
statement.setObject(1, room.id)
statement.setObject(2, hostId)
statement.executeUpdate()
}
connection.commit()
} catch (error: Throwable) {
connection.rollback()
throw error
} finally {
connection.autoCommit = true
}
}
return room
}
fun find(roomId: UUID): RoomRecord? = Database.useConnection { connection ->
connection.prepareStatement("SELECT id, title, status, created_by FROM rooms WHERE id = ?").use { statement ->
statement.setObject(1, roomId)
statement.executeQuery().use { result ->
if (result.next()) {
RoomRecord(
result.getObject("id", UUID::class.java),
result.getString("title"),
result.getString("status"),
result.getObject("created_by", UUID::class.java),
)
} else null
}
}
}
fun member(roomId: UUID, userId: UUID): RoomMember? = Database.useConnection { connection ->
connection.prepareStatement(
"SELECT room_id, user_id, role FROM room_members WHERE room_id = ? AND user_id = ?",
).use { statement ->
statement.setObject(1, roomId)
statement.setObject(2, userId)
statement.executeQuery().use { result ->
if (result.next()) {
RoomMember(
result.getObject("room_id", UUID::class.java),
result.getObject("user_id", UUID::class.java),
RoomRole.valueOf(result.getString("role")),
)
} else null
}
}
}
fun joinAsViewer(roomId: UUID, userId: UUID): RoomMember {
val room = find(roomId) ?: throw ApiException("房间不存在", 404)
if (room.status != "open") throw ApiException("房间已关闭", 403)
Database.useConnection { connection ->
connection.prepareStatement(
"INSERT INTO room_members (room_id, user_id, role) VALUES (?, ?, 'viewer') ON CONFLICT DO NOTHING",
).use { statement ->
statement.setObject(1, roomId)
statement.setObject(2, userId)
statement.executeUpdate()
}
}
return member(roomId, userId) ?: error("Room membership was not created")
}
fun updateRole(roomId: UUID, userId: UUID, role: RoomRole): RoomMember {
Database.useConnection { connection ->
connection.prepareStatement(
"UPDATE room_members SET role = ? WHERE room_id = ? AND user_id = ?",
).use { statement ->
statement.setString(1, role.name)
statement.setObject(2, roomId)
statement.setObject(3, userId)
if (statement.executeUpdate() == 0) throw ApiException("房间成员不存在", 404)
}
}
return member(roomId, userId) ?: error("Room membership was not updated")
}
}
fun hashInviteCode(value: String): String =
MessageDigest.getInstance("SHA-256")
.digest(value.toByteArray(Charsets.UTF_8))
.joinToString("") { byte -> "%02x".format(byte) }
private fun java.sql.ResultSet.toUser(): User =
User(
id = getObject("id", UUID::class.java),
username = getString("username"),
passwordHash = getString("password_hash"),
displayName = getString("display_name"),
isAdmin = getBoolean("is_admin"),
)
+179 -125
View File
@@ -1,12 +1,20 @@
package top.OrangeRoll
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.*
import io.ktor.server.application.Application
import io.ktor.server.application.call
import io.ktor.server.application.install
import io.ktor.server.auth.authenticate
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.plugins.cors.routing.CORS
import io.ktor.server.plugins.statuspages.StatusPages
import io.ktor.server.request.receive
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.response.respond
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.routing
import io.ktor.serialization.kotlinx.json.json
import java.nio.charset.StandardCharsets
import java.time.Instant
@@ -16,10 +24,14 @@ import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.mindrot.jbcrypt.BCrypt
fun Application.configureRouting() {
install(ContentNegotiation) {
json()
install(ContentNegotiation) { json() }
install(StatusPages) {
exception<ApiException> { call, error ->
call.respond(HttpStatusCode.fromValue(error.status), ErrorResponse(error.message))
}
}
install(CORS) {
val configuredOrigins = this@configureRouting.environment.config
@@ -29,95 +41,192 @@ fun Application.configureRouting() {
?.map(String::trim)
?.filter(String::isNotEmpty)
.orEmpty()
configuredOrigins.ifEmpty { listOf("http://localhost:5173") }.forEach { origin ->
val host = origin.removePrefix("http://").removePrefix("https://")
allowHost(
host = host,
host = origin.removePrefix("http://").removePrefix("https://"),
schemes = listOf(if (origin.startsWith("https://")) "https" else "http"),
)
}
allowMethod(HttpMethod.Post)
allowMethod(HttpMethod.Get)
allowHeader(HttpHeaders.ContentType)
allowHeader(HttpHeaders.Authorization)
}
routing {
get("/health") {
call.respond(HealthResponse(status = "ok"))
get("/health") { call.respond(HealthResponse("ok")) }
post("/api/auth/login") {
val request = call.receive<LoginRequest>()
val user = UserRepository.findByUsername(request.username.normalizedUsername())
?.takeIf { BCrypt.checkpw(request.password, it.passwordHash) }
?: throw ApiException("用户名或密码错误", 401)
call.respond(authResponse(user))
}
post("/api/rooms/{roomId}/token") {
val roomName = call.parameters["roomId"]?.trim().orEmpty()
if (!ROOM_NAME.matches(roomName)) {
post("/api/auth/register") {
val request = call.receive<RegisterRequest>()
val username = request.username.normalizedUsername()
validateCredentials(username, request.password, request.displayName)
if (UserRepository.findByUsername(username) != null) {
throw ApiException("用户名已存在", 409)
}
val user = UserRepository.register(
inviteCode = request.inviteCode.trim(),
username = username,
password = request.password,
displayName = request.displayName.trim(),
)
call.respond(HttpStatusCode.Created, authResponse(user))
}
authenticate("auth-jwt") {
get("/api/auth/me") {
call.respond(UserResponse(call.requireCurrentUser()))
}
post("/api/admin/invitations") {
val admin = call.requireAdministrator()
val request = call.receive<CreateInvitationRequest>()
val type = runCatching { InvitationType.valueOf(request.type.lowercase()) }.getOrNull()
?: throw ApiException("邀请码类型必须是 user 或 admin", 400)
if (request.expiresInHours !in 1..720) {
throw ApiException("邀请码有效期必须在 1 到 720 小时之间", 400)
}
val expiresAt = Instant.now().plusSeconds(request.expiresInHours * 3600L)
val code = InvitationRepository.create(admin.id, type, expiresAt)
call.respond(
HttpStatusCode.BadRequest,
ErrorResponse("roomId must contain 3-64 letters, numbers, underscores, or hyphens"),
HttpStatusCode.Created,
InvitationResponse(code, type.name, expiresAt.toString()),
)
return@post
}
val request = call.receive<TokenRequest>()
val role = runCatching { RoomRole.valueOf(request.role.lowercase()) }.getOrNull()
if (role == null) {
call.respond(HttpStatusCode.BadRequest, ErrorResponse("role must be host, publisher, or viewer"))
return@post
post("/api/rooms") {
val user = call.requireCurrentUser()
val title = call.receive<CreateRoomRequest>().title.trim()
if (title.length !in 1..128) throw ApiException("房间标题必须为 1-128 个字符", 400)
call.respond(HttpStatusCode.Created, RoomResponse(RoomRepository.create(user.id, title), RoomRole.host))
}
val participantName = request.participantName.trim()
if (participantName.length !in 1..64) {
call.respond(HttpStatusCode.BadRequest, ErrorResponse("participantName must be 1-64 characters"))
return@post
get("/api/rooms/{roomId}") {
val user = call.requireCurrentUser()
val room = RoomRepository.find(call.roomId()) ?: throw ApiException("房间不存在", 404)
val membership = RoomRepository.member(room.id, user.id)
if (membership == null && !user.isAdmin) throw ApiException("没有访问此房间的权限", 403)
call.respond(RoomResponse(room, membership?.role))
}
val apiKey = environment.config.propertyOrNull("livekit.apiKey")?.getString()
?: System.getenv("LIVEKIT_API_KEY")
val apiSecret = environment.config.propertyOrNull("livekit.apiSecret")?.getString()
?: System.getenv("LIVEKIT_API_SECRET")
val publicUrl = environment.config.propertyOrNull("livekit.publicUrl")?.getString()
?: System.getenv("LIVEKIT_PUBLIC_URL")
if (apiKey.isNullOrBlank() || apiSecret.isNullOrBlank() || publicUrl.isNullOrBlank()) {
application.log.error("LiveKit environment variables are not configured")
call.respond(HttpStatusCode.ServiceUnavailable, ErrorResponse("Video service is not configured"))
return@post
post("/api/rooms/{roomId}/join") {
val user = call.requireCurrentUser()
call.respond(RoomMemberResponse(RoomRepository.joinAsViewer(call.roomId(), user.id)))
}
val identity = "dev-${UUID.randomUUID()}"
val token = LiveKitTokenIssuer(apiKey, apiSecret).issue(
roomName = roomName,
identity = identity,
participantName = participantName,
role = role,
)
post("/api/rooms/{roomId}/members/{userId}/role") {
val requester = call.requireCurrentUser()
val roomId = call.roomId()
val requesterMembership = RoomRepository.member(roomId, requester.id)
if (requesterMembership?.role != RoomRole.host && !requester.isAdmin) {
throw ApiException("需要 host 或管理员权限", 403)
}
val targetUserId = call.parameters["userId"]?.let { runCatching { UUID.fromString(it) }.getOrNull() }
?: throw ApiException("userId 必须是 UUID", 400)
val role = runCatching { RoomRole.valueOf(call.receive<UpdateMemberRoleRequest>().role.lowercase()) }.getOrNull()
?: throw ApiException("角色必须是 host、publisher 或 viewer", 400)
call.respond(RoomMemberResponse(RoomRepository.updateRole(roomId, targetUserId, role)))
}
call.respond(
TokenResponse(
roomName = roomName,
participantIdentity = identity,
participantName = participantName,
livekitUrl = publicUrl,
token = token,
role = role.name,
),
)
post("/api/rooms/{roomId}/token") {
val user = call.requireCurrentUser()
val room = RoomRepository.find(call.roomId()) ?: throw ApiException("房间不存在", 404)
val membership = RoomRepository.member(room.id, user.id)
?: throw ApiException("请先加入房间", 403)
val liveKit = liveKitSettings()
val token = LiveKitTokenIssuer(liveKit.apiKey, liveKit.apiSecret).issue(
roomName = room.id.toString(),
identity = user.id.toString(),
participantName = user.displayName,
role = membership.role,
)
call.respond(
TokenResponse(
roomName = room.id.toString(),
participantIdentity = user.id.toString(),
participantName = user.displayName,
livekitUrl = liveKit.publicUrl,
token = token,
role = membership.role.name,
),
)
}
}
}
}
private val ROOM_NAME = Regex("[A-Za-z0-9_-]{3,64}")
private fun Application.authResponse(user: User) =
AuthResponse(issueApplicationToken(user), UserResponse(user))
@Serializable
data class HealthResponse(val status: String)
private fun io.ktor.server.application.ApplicationCall.requireCurrentUser(): User =
UserRepository.findById(currentUserId()) ?: throw ApiException("用户不存在", 401)
@Serializable
data class TokenRequest(
val participantName: String,
val role: String = "viewer",
)
private fun io.ktor.server.application.ApplicationCall.requireAdministrator(): User =
requireCurrentUser().takeIf { it.isAdmin } ?: throw ApiException("需要管理员权限", 403)
@Serializable
data class TokenResponse(
private fun io.ktor.server.application.ApplicationCall.roomId(): UUID =
parameters["roomId"]?.let { runCatching { UUID.fromString(it) }.getOrNull() }
?: throw ApiException("roomId 必须是 UUID", 400)
private fun String.normalizedUsername(): String {
val normalized = trim().lowercase()
if (!Regex("[a-z0-9_-]{3,64}").matches(normalized)) {
throw ApiException("用户名只能包含 3-64 个小写字母、数字、下划线或连字符", 400)
}
return normalized
}
private fun validateCredentials(username: String, password: String, displayName: String) {
username.normalizedUsername()
if (password.length !in 8..128) throw ApiException("密码必须为 8-128 个字符", 400)
if (displayName.trim().length !in 1..64) throw ApiException("显示名称必须为 1-64 个字符", 400)
}
private fun Application.liveKitSettings(): LiveKitSettings {
val config = environment.config
fun value(path: String, variable: String): String =
config.propertyOrNull(path)?.getString()
?: System.getenv(variable)
?: throw ApiException("视频服务未配置", 503)
return LiveKitSettings(
value("livekit.apiKey", "LIVEKIT_API_KEY"),
value("livekit.apiSecret", "LIVEKIT_API_SECRET"),
value("livekit.publicUrl", "LIVEKIT_PUBLIC_URL"),
)
}
private data class LiveKitSettings(val apiKey: String, val apiSecret: String, val publicUrl: String)
class ApiException(override val message: String, val status: Int) : RuntimeException(message)
enum class RoomRole { host, publisher, viewer }
@Serializable data class HealthResponse(val status: String)
@Serializable data class ErrorResponse(val message: String)
@Serializable data class LoginRequest(val username: String, val password: String)
@Serializable data class RegisterRequest(val inviteCode: String, val username: String, val password: String, val displayName: String)
@Serializable data class CreateInvitationRequest(val type: String, val expiresInHours: Int = 168)
@Serializable data class CreateRoomRequest(val title: String)
@Serializable data class UpdateMemberRoleRequest(val role: String)
@Serializable data class AuthResponse(val token: String, val user: UserResponse)
@Serializable data class UserResponse(val id: String, val username: String, val displayName: String, val isAdmin: Boolean) {
constructor(user: User) : this(user.id.toString(), user.username, user.displayName, user.isAdmin)
}
@Serializable data class InvitationResponse(val code: String, val type: String, val expiresAt: String)
@Serializable data class RoomResponse(val id: String, val title: String, val status: String, val role: String?) {
constructor(room: RoomRecord, role: RoomRole?) : this(room.id.toString(), room.title, room.status, role?.name)
}
@Serializable data class RoomMemberResponse(val roomId: String, val role: String) {
constructor(member: RoomMember) : this(member.roomId.toString(), member.role.name)
}
@Serializable data class TokenResponse(
val roomName: String,
val participantIdentity: String,
val participantName: String,
@@ -126,80 +235,25 @@ data class TokenResponse(
val role: String,
)
@Serializable
data class ErrorResponse(val message: String)
private enum class RoomRole {
host,
publisher,
viewer,
}
/**
* Creates a standard LiveKit HS256 access token. The API secret remains exclusively on the server.
* Replace the development role supplied by the client with application JWT authorization before production.
*/
private class LiveKitTokenIssuer(
private val apiKey: String,
private val apiSecret: String,
) {
fun issue(
roomName: String,
identity: String,
participantName: String,
role: RoomRole,
): String {
private class LiveKitTokenIssuer(private val apiKey: String, private val apiSecret: String) {
fun issue(roomName: String, identity: String, participantName: String, role: RoomRole): String {
val now = Instant.now().epochSecond
val canPublish = role == RoomRole.host || role == RoomRole.publisher
val payload = Json.encodeToString(
LiveKitClaims.serializer(),
LiveKitClaims(
iss = apiKey,
sub = identity,
name = participantName,
nbf = now,
exp = now + TOKEN_TTL_SECONDS,
video = VideoGrant(
roomJoin = true,
room = roomName,
canPublish = canPublish,
canSubscribe = true,
canPublishData = canPublish,
),
),
LiveKitClaims(apiKey, identity, participantName, now, now + 600, VideoGrant(true, roomName, canPublish, true, canPublish)),
)
val header = base64Url("""{"alg":"HS256","typ":"JWT"}""")
val body = base64Url(payload)
val unsignedToken = "$header.$body"
val unsignedToken = header + "." + base64Url(payload)
val signature = Mac.getInstance("HmacSHA256")
.apply { init(SecretKeySpec(apiSecret.toByteArray(StandardCharsets.UTF_8), "HmacSHA256")) }
.doFinal(unsignedToken.toByteArray(StandardCharsets.UTF_8))
return "$unsignedToken.${Base64.getUrlEncoder().withoutPadding().encodeToString(signature)}"
return unsignedToken + "." + Base64.getUrlEncoder().withoutPadding().encodeToString(signature)
}
private fun base64Url(value: String): String =
Base64.getUrlEncoder().withoutPadding().encodeToString(value.toByteArray(StandardCharsets.UTF_8))
private companion object {
const val TOKEN_TTL_SECONDS = 10 * 60L
}
}
@Serializable
private data class LiveKitClaims(
val iss: String,
val sub: String,
val name: String,
val nbf: Long,
val exp: Long,
val video: VideoGrant,
)
@Serializable
private data class VideoGrant(
val roomJoin: Boolean,
val room: String,
val canPublish: Boolean,
val canSubscribe: Boolean,
val canPublishData: Boolean,
)
@Serializable private data class LiveKitClaims(val iss: String, val sub: String, val name: String, val nbf: Long, val exp: Long, val video: VideoGrant)
@Serializable private data class VideoGrant(val roomJoin: Boolean, val room: String, val canPublish: Boolean, val canSubscribe: Boolean, val canPublishData: Boolean)
@@ -0,0 +1,73 @@
package top.OrangeRoll
import com.auth0.jwt.JWT
import com.auth0.jwt.JWTVerifier
import com.auth0.jwt.algorithms.Algorithm
import io.ktor.server.application.Application
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.install
import io.ktor.server.auth.Authentication
import io.ktor.server.auth.jwt.JWTPrincipal
import io.ktor.server.auth.jwt.jwt
import io.ktor.server.auth.principal
import java.time.Instant
import java.util.Date
data class AuthSettings(
val issuer: String,
val audience: String,
val realm: String,
val secret: String,
)
fun Application.authSettings(): AuthSettings {
val config = environment.config
fun value(path: String, environmentVariable: String): String =
config.propertyOrNull(path)?.getString()
?: System.getenv(environmentVariable)
?: error("$path is not configured")
return AuthSettings(
issuer = value("auth.issuer", "AUTH_JWT_ISSUER"),
audience = value("auth.audience", "AUTH_JWT_AUDIENCE"),
realm = value("auth.realm", "AUTH_JWT_REALM"),
secret = value("auth.jwtSecret", "AUTH_JWT_SECRET"),
)
}
fun Application.configureSecurity() {
val settings = authSettings()
install(Authentication) {
jwt("auth-jwt") {
realm = settings.realm
verifier(jwtVerifier(settings))
validate { credential ->
credential.payload.subject
?.let { JWTPrincipal(credential.payload) }
}
}
}
}
fun Application.issueApplicationToken(user: User): String {
val settings = authSettings()
return JWT.create()
.withIssuer(settings.issuer)
.withAudience(settings.audience)
.withSubject(user.id.toString())
.withClaim("username", user.username)
.withExpiresAt(Date.from(Instant.now().plusSeconds(8 * 60 * 60)))
.sign(Algorithm.HMAC256(settings.secret))
}
fun ApplicationCall.currentUserId(): java.util.UUID =
java.util.UUID.fromString(
principal<JWTPrincipal>()?.payload?.subject
?: error("Authenticated user is missing from the token"),
)
private fun jwtVerifier(settings: AuthSettings): JWTVerifier =
JWT.require(Algorithm.HMAC256(settings.secret))
.withIssuer(settings.issuer)
.withAudience(settings.audience)
.build()
@@ -10,3 +10,19 @@ livekit:
apiKey: devkey
apiSecret: replace-with-a-long-random-secret
publicUrl: ws://localhost:7880
database:
jdbcUrl: jdbc:postgresql://localhost:5432/screenshare
user: screenshare
password: change-me
auth:
issuer: screen-share-api
audience: screen-share-web
realm: screen-share
jwtSecret: replace-with-a-different-long-random-secret
bootstrapAdmin:
username: admin
password: change-this-admin-password
displayName: 管理员
@@ -0,0 +1,41 @@
CREATE TABLE users (
id UUID PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE rooms (
id UUID PRIMARY KEY,
slug VARCHAR(64) NOT NULL UNIQUE,
title VARCHAR(128) NOT NULL,
created_by UUID NOT NULL REFERENCES users(id),
status VARCHAR(16) NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'closed')),
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
closed_at TIMESTAMPTZ
);
CREATE TABLE room_members (
room_id UUID NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(16) NOT NULL
CHECK (role IN ('host', 'publisher', 'viewer')),
joined_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (room_id, user_id)
);
CREATE TABLE audit_logs (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
room_id UUID REFERENCES rooms(id) ON DELETE SET NULL,
actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
event_type VARCHAR(64) NOT NULL,
event_data JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_rooms_created_by ON rooms(created_by);
CREATE INDEX idx_room_members_user_id ON room_members(user_id);
CREATE INDEX idx_audit_logs_room_id_created_at ON audit_logs(room_id, created_at DESC);
@@ -0,0 +1,17 @@
ALTER TABLE users
ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE invitations (
id UUID PRIMARY KEY,
code_hash CHAR(64) NOT NULL UNIQUE,
invitation_type VARCHAR(16) NOT NULL
CHECK (invitation_type IN ('user', 'admin')),
created_by UUID NOT NULL REFERENCES users(id),
expires_at TIMESTAMPTZ NOT NULL,
used_by UUID REFERENCES users(id),
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK ((used_by IS NULL AND used_at IS NULL) OR (used_by IS NOT NULL AND used_at IS NOT NULL))
);
CREATE INDEX idx_invitations_created_by ON invitations(created_by);