Initial commit

This commit is contained in:
SKBotNL committed 2026-05-22 14:40:22 +02:00
commit f2989deb6b
79 files changed
+1915

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
Thumbs.db
.DS_Store
.gradle
build/
target/
out/
.micronaut/
.idea
*.iml
*.ipr
*.iws
.project
.settings
.classpath
.factorypath
**/application*.yml
Whitespace-only changes.
+24
View File
@@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org/>
+15
View File
@@ -0,0 +1,15 @@
# Staff-Auth
An [Ory Hydra](https://github.com/ory/hydra) authentication and authorization provider.
> [!CAUTION]
> This server implements no rate limiting on its own, make sure rate limiting is configured in your reverse proxy, specifically on `/login`.
## Building
JVM: `./gradlew shadowJar`\
GraalVM Native: `./gradlew nativeCompile`
## Configuration
Copy [application.yml.example](application.yml.example), rename it to whatever you like, fill it in and run the server binary with `MICRONAUT_CONFIG_FILE=path/to/your/application.yml`.
## Initial setup
Run the server binary with `--initialSetup.adminUuid=your-uuid-here`.
+27
View File
@@ -0,0 +1,27 @@
micronaut:
server:
port: 8080
security:
oauth2:
clients:
staffauth:
client-id: staff-auth-panel
client-secret: secret
openid:
issuer: http://localhost:4444
end-session:
url: http://localhost:4444/oauth2/sessions/logout
plugin:
address: http://localhost:65226
frontend:
host: http://localhost:3000
hydra:
host: http://localhost:4445
db:
url: postgresql://localhost:5432/staffauth
user: username
password: password
+140
View File
@@ -0,0 +1,140 @@
import com.google.protobuf.gradle.id
plugins {
id("org.jetbrains.kotlin.jvm") version "2.3.21"
id("org.jetbrains.kotlin.plugin.allopen") version "2.3.21"
id("com.google.devtools.ksp") version "2.3.8"
id("io.micronaut.application") version "4.6.2"
id("com.gradleup.shadow") version "8.3.9"
id("com.google.protobuf") version "0.10.0"
id("io.micronaut.aot") version "4.6.2"
}
version = "0.1"
group = "net.trueog.staffauth"
val kotlinVersion = project.properties["kotlinVersion"]
repositories {
mavenCentral()
}
dependencies {
ksp("io.micronaut.data:micronaut-data-processor")
ksp("io.micronaut:micronaut-http-validation")
ksp("io.micronaut.security:micronaut-security-annotations")
ksp("io.micronaut.serde:micronaut-serde-processor")
ksp("io.micronaut.validation:micronaut-validation-processor")
implementation("com.google.protobuf:protobuf-kotlin:4.34.1")
implementation("io.micronaut.grpc:micronaut-grpc-runtime")
implementation("io.micronaut.validation:micronaut-validation")
implementation("io.grpc:grpc-kotlin-stub:1.5.0")
implementation("io.micronaut.data:micronaut-data-r2dbc")
implementation("io.micronaut.r2dbc:micronaut-r2dbc-core")
implementation("io.micronaut.flyway:micronaut-flyway")
implementation("io.micronaut.kotlin:micronaut-kotlin-runtime")
implementation("io.micronaut.security:micronaut-security-oauth2")
implementation("io.micronaut.security:micronaut-security-jwt")
implementation("org.springframework.security:spring-security-crypto:7.0.5")
implementation("org.bouncycastle:bcprov-jdk18on:1.84")
implementation("org.slf4j:jcl-over-slf4j")
implementation("io.micronaut.serde:micronaut-serde-jackson")
implementation("io.micronaut.sql:micronaut-jdbc-hikari")
// implementation("io.micronaut.views:micronaut-views-fieldset")
// implementation("io.micronaut.views:micronaut-views-thymeleaf")
implementation("org.jetbrains.kotlin:kotlin-reflect:${kotlinVersion}")
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:${kotlinVersion}")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
implementation("dev.samstevens.totp:totp:1.7.1")
implementation("com.github.ben-manes.caffeine:caffeine:3.2.4")
implementation("sh.ory.hydra:hydra-client:26.2.0")
implementation("io.micronaut:micronaut-http-client")
runtimeOnly("ch.qos.logback:logback-classic")
runtimeOnly("org.flywaydb:flyway-database-postgresql")
runtimeOnly("org.postgresql:postgresql")
runtimeOnly("org.postgresql:r2dbc-postgresql")
runtimeOnly("com.fasterxml.jackson.module:jackson-module-kotlin") {
exclude(group = "org.jetbrains.kotlin", module = "kotlin-reflect")
}
runtimeOnly("org.yaml:snakeyaml")
testImplementation("io.micronaut:micronaut-http-client")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
application {
mainClass = "net.trueog.staffauth.ApplicationKt"
}
java {
sourceCompatibility = JavaVersion.toVersion("17")
}
graalvmNative.toolchainDetection = false
sourceSets {
main {
java {
srcDirs("build/generated/source/proto/main/grpc")
srcDirs("build/generated/source/proto/main/grpckt")
srcDirs("build/generated/source/proto/main/java")
}
}
}
protobuf {
protoc {
artifact = "com.google.protobuf:protoc:4.34.1"
}
plugins {
id("grpc") {
artifact = "io.grpc:protoc-gen-grpc-java:1.81.0"
}
id("grpckt") {
artifact = "io.grpc:protoc-gen-grpc-kotlin:1.5.0:jdk8@jar"
}
}
generateProtoTasks {
ofSourceSet("main").forEach {
it.plugins {
// Apply the "grpc" plugin whose spec is defined above, without options.
id("grpc")
id("grpckt")
}
it.builtins {
id("kotlin")
}
}
}
}
micronaut {
runtime("netty")
testRuntime("junit5")
processing {
incremental(true)
annotations("net.trueog.staffauth.*")
}
aot {
// Please review carefully the optimizations enabled below
// Check https://micronaut-projects.github.io/micronaut-aot/latest/guide/ for more details
optimizeServiceLoading = false
convertYamlToJava = false
precomputeOperations = true
cacheEnvironment = true
optimizeClassLoading = true
deduceEnvironment = true
optimizeNetty = true
replaceLogbackXml = true
}
}
+3
View File
@@ -0,0 +1,3 @@
micronautVersion=4.10.14
kotlinVersion=2.3.21
org.gradle.jvmargs=-Xmx4096M
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+1
View File
@@ -0,0 +1 @@
rootProject.name="Staff-Auth"
@@ -0,0 +1,8 @@
package net.trueog.staffauth
import io.micronaut.runtime.Micronaut.run
fun main(args: Array<String>) {
run(*args)
}
@@ -0,0 +1,19 @@
package net.trueog.staffauth
import io.micronaut.context.annotation.Factory
import io.micronaut.context.annotation.Value
import jakarta.inject.Singleton
import sh.ory.hydra.ApiClient
import sh.ory.hydra.api.OAuth2Api
import java.util.UUID
@Factory
class HydraFactory {
@Value($$"${hydra.host}")
private lateinit var hydraHost: String
@Singleton
fun oAuth2Api() = OAuth2Api(ApiClient().apply {
basePath = hydraHost
})
}
@@ -0,0 +1,52 @@
package net.trueog.staffauth
import io.micronaut.context.annotation.Value
import io.micronaut.context.event.ApplicationEventListener
import io.micronaut.context.event.StartupEvent
import jakarta.inject.Singleton
import kotlinx.coroutines.flow.fold
import kotlinx.coroutines.runBlocking
import net.trueog.staffauth.dto.admin.CreateInviteDto
import net.trueog.staffauth.entity.User
import net.trueog.staffauth.model.Role
import net.trueog.staffauth.repository.InviteRepository
import net.trueog.staffauth.repository.UserRepository
import net.trueog.staffauth.service.InviteService
import org.slf4j.LoggerFactory
import java.net.URI
import java.util.*
import kotlin.system.exitProcess
@Singleton
class InitialConfig(
private val userRepository: UserRepository,
private val inviteService: InviteService,
private val inviteRepository: InviteRepository
) : ApplicationEventListener<StartupEvent> {
private val log = LoggerFactory.getLogger(InitialConfig::class.java)
@Value($$"${initialSetup.adminUuid:}")
private var adminUuid: UUID? = null
@Value($$"${frontend.host}")
private lateinit var frontendHost: String
override fun onApplicationEvent(event: StartupEvent): Unit = runBlocking {
// If no users exist or if no users are set up
if (userRepository.count() == 0L || userRepository.findAll()
.fold(true) { acc, user -> acc && (!user.isSetUp) }
) {
if (adminUuid == null) {
log.error("Not set up but initialSetup.adminUuid not set or invalid, exiting...")
exitProcess(1)
}
inviteRepository.deleteAll()
userRepository.deleteAll()
val user = userRepository.save(User(null, Role.ADMIN, adminUuid!!, null, null, false))
val invite = inviteService.create(CreateInviteDto(user.id!!))
log.info("Initial setup, created invite for admin user: ${URI(frontendHost).resolve("/setup?token=${invite.token}")}")
} else if (adminUuid != null) {
log.warn("Already set up but initialSetup.adminUuid is still set, can be removed")
}
}
}
@@ -0,0 +1,19 @@
package net.trueog.staffauth
import dev.samstevens.totp.code.CodeVerifier
import dev.samstevens.totp.code.DefaultCodeGenerator
import dev.samstevens.totp.code.DefaultCodeVerifier
import dev.samstevens.totp.secret.DefaultSecretGenerator
import dev.samstevens.totp.secret.SecretGenerator
import dev.samstevens.totp.time.SystemTimeProvider
import io.micronaut.context.annotation.Factory
import jakarta.inject.Singleton
@Factory
class TotpFactory {
@Singleton
fun secretGenerator(): SecretGenerator = DefaultSecretGenerator()
@Singleton
fun codeVerifier(): CodeVerifier = DefaultCodeVerifier(DefaultCodeGenerator(), SystemTimeProvider())
}
@@ -0,0 +1,15 @@
package net.trueog.staffauth.client
import io.micronaut.http.annotation.Get
import io.micronaut.http.client.annotation.Client
import net.trueog.staffauth.client.dto.MinecraftProfileDto
import java.util.*
@Client("https://api.mojang.com/")
interface MinecraftClient {
@Get("users/profiles/minecraft/{username}")
suspend fun getByUsername(username: String): MinecraftProfileDto?
@Get("user/profile/{uuid}")
suspend fun getByUuid(uuid: UUID): MinecraftProfileDto?
}
@@ -0,0 +1,11 @@
package net.trueog.staffauth.client.dto
import io.micronaut.serde.annotation.Serdeable
import java.util.*
val uuidRegex = """(\w{8})(\w{4})(\w{4})(\w{4})(\w{12})""".toRegex()
@Serdeable
data class MinecraftProfileDto(val name: String, val id: String) {
var uuid: UUID = UUID.fromString(uuidRegex.replace(id, $$"$1-$2-$3-$4-$5"))
}
@@ -0,0 +1,16 @@
package net.trueog.staffauth.controller
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Get
import io.micronaut.security.annotation.Secured
import io.micronaut.security.authentication.Authentication
import io.micronaut.security.rules.SecurityRule
@Controller("/me")
@Secured(SecurityRule.IS_AUTHENTICATED)
class MeResource {
@Get
fun index(auth: Authentication): Map<String, Any> {
return auth.attributes
}
}
@@ -0,0 +1,73 @@
package net.trueog.staffauth.controller
import io.grpc.Status
import io.grpc.StatusException
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.HttpStatus
import io.micronaut.http.annotation.*
import io.micronaut.security.annotation.Secured
import io.micronaut.security.rules.SecurityRule
import jakarta.validation.Valid
import net.trueog.staffauth.dto.setup.DetailsDto
import net.trueog.staffauth.dto.setup.TokenDto
import net.trueog.staffauth.dto.setup.TotpDto
import net.trueog.staffauth.dto.setup.TotpVerifyDto
import net.trueog.staffauth.exception.IncorrectTotpCodeException
import net.trueog.staffauth.exception.setup.DeactivatedException
import net.trueog.staffauth.exception.setup.IncorrectSetupStageException
import net.trueog.staffauth.model.SetupStage
import net.trueog.staffauth.service.SetupService
@Controller("/setup")
@Secured(SecurityRule.IS_ANONYMOUS)
open class SetupController(
private val setupService: SetupService
) {
@Get("/currentStage")
suspend fun currentStage(@QueryValue("token") token: String): String = setupService.getCurrentStage(token)
@Post("/details")
open suspend fun details(@Valid @Body detailsDto: DetailsDto) {
return setupService.details(detailsDto.token, detailsDto)
}
@Post("/minecraftcheck")
suspend fun minecraftCheck(@Body tokenDto: TokenDto, request: HttpRequest<*>): Boolean {
return setupService.minecraftCheck(tokenDto.token, request.remoteAddress.address.hostAddress)
}
@Post("/totpsetup")
suspend fun totpSetup(@Body tokenDto: TokenDto): TotpDto {
return setupService.generateTotp(tokenDto.token)
}
@Post("/totpverify")
suspend fun totpVerify(@Body totpVerifyDto: TotpVerifyDto) {
setupService.verifyTotp(totpVerifyDto.token, totpVerifyDto.code)
setupService.finalize(totpVerifyDto.token)
}
@Error(exception = DeactivatedException::class)
fun onDeactivated(): HttpResponse<String> = HttpResponse.unauthorized<String>().body("DEACTIVATED")
@Error(exception = IncorrectTotpCodeException::class)
fun onIncorrectTotpCode(): HttpResponse<String> = HttpResponse.unauthorized<String>().body("INCORRECT_TOTP_CODE")
@Error(exception = StatusException::class)
fun onGrpcStatusException(exception: StatusException): HttpResponse<String> {
if (exception.status != Status.DEADLINE_EXCEEDED) throw exception
return HttpResponse.status<Unit>(HttpStatus.GATEWAY_TIMEOUT).body("MINECRAFT_CHECK_TIMEOUT")
}
@Error(exception = IncorrectSetupStageException::class)
fun onIncorrectSetupStage(exception: IncorrectSetupStageException): HttpResponse<String> =
HttpResponse.status<Unit>(HttpStatus.CONFLICT).body(
when (exception.correctSetupStage) {
is SetupStage.AwaitingMinecraftCheck -> "MINECRAFT_CHECK"
is SetupStage.AwaitingTotp -> "TOTP"
is SetupStage.AwaitingTotpVerify -> "TOTP"
is SetupStage.AwaitingFinalize -> "FINALIZE"
}
)
}
@@ -0,0 +1,30 @@
package net.trueog.staffauth.controller.admin
import io.micronaut.http.HttpResponse
import io.micronaut.http.HttpStatus
import io.micronaut.http.annotation.*
import io.micronaut.security.annotation.Secured
import kotlinx.coroutines.flow.Flow
import net.trueog.staffauth.dto.admin.CreateInviteDto
import net.trueog.staffauth.dto.admin.InviteDto
import net.trueog.staffauth.service.InviteService
@Controller("/invite")
@Secured("ADMIN")
class InviteController(private val inviteService: InviteService) {
@Get
fun getAll(): Flow<InviteDto> = inviteService.list()
@Get("/{id}")
suspend fun getById(@PathVariable id: Long): HttpResponse<InviteDto> = inviteService.get(id)?.let {
HttpResponse.ok(it)
} ?: HttpResponse.notFound()
@Post
@Status(HttpStatus.CREATED)
suspend fun create(@Body createInviteDto: CreateInviteDto): InviteDto = inviteService.create(createInviteDto)
@Delete("/{id}")
suspend fun delete(@PathVariable id: Long): HttpResponse<Unit> =
if (inviteService.delete(id) > 0) HttpResponse.noContent() else HttpResponse.notFound()
}
@@ -0,0 +1,43 @@
package net.trueog.staffauth.controller.admin
import io.micronaut.http.HttpResponse
import io.micronaut.http.HttpStatus
import io.micronaut.http.annotation.*
import io.micronaut.security.annotation.Secured
import io.micronaut.security.authentication.Authentication
import kotlinx.coroutines.flow.Flow
import net.trueog.staffauth.dto.admin.CreateUserDto
import net.trueog.staffauth.dto.admin.UpdateUserDto
import net.trueog.staffauth.dto.admin.UserDto
import net.trueog.staffauth.exception.user.DeactivateSelfException
import net.trueog.staffauth.service.UserService
@Controller("/user")
@Secured("ADMIN")
class UserController(private val userService: UserService) {
@Get
fun getAll(): Flow<UserDto> = userService.list()
@Get("/{id}")
suspend fun getById(@PathVariable id: Long): HttpResponse<UserDto> = userService.get(id)?.let {
HttpResponse.ok(it)
} ?: HttpResponse.notFound()
@Post
@Status(HttpStatus.CREATED)
suspend fun create(@Body createUserDto: CreateUserDto): UserDto = userService.create(createUserDto)
@Patch("/{id}")
suspend fun update(@Body updateUserDto: UpdateUserDto, auth: Authentication): HttpResponse<UserDto> =
userService.update(updateUserDto, auth)?.let {
HttpResponse.ok(it)
} ?: HttpResponse.notFound()
@Delete("/{id}")
suspend fun delete(@PathVariable id: Long): HttpResponse<Unit> =
if (userService.delete(id) > 0) HttpResponse.noContent() else HttpResponse.notFound()
@Error(exception = DeactivateSelfException::class)
fun onDeactivateSelf(): HttpResponse<String> = HttpResponse.badRequest("DEACTIVATE_SELF")
}
@@ -0,0 +1,47 @@
package net.trueog.staffauth.controller.oauth
import io.micronaut.http.HttpResponse
import io.micronaut.http.HttpStatus
import io.micronaut.http.annotation.*
import io.micronaut.security.annotation.Secured
import io.micronaut.security.rules.SecurityRule
import net.trueog.staffauth.dto.consent.ConsentDto
import net.trueog.staffauth.service.oauth.ConsentService
import sh.ory.hydra.ApiException
@Controller("/consent")
@Secured(SecurityRule.IS_ANONYMOUS)
class ConsentController(
private val consentService: ConsentService
) {
@Get
fun index(@QueryValue("consent_challenge") consentChallenge: String): Map<String, Any?> {
val consentRequest = consentService.getConsentRequest(consentChallenge)
return mapOf(
"skip" to (consentRequest.skip == true || consentRequest.requestedScope?.all { it == "openid" } == true),
"clientName" to consentRequest.client?.run { clientName?.takeIf { it.isNotBlank() } ?: clientId },
"scopes" to consentRequest.requestedScope?.filter { it != "openid" }
)
}
@Post
suspend fun indexSubmit(@Body consentDto: ConsentDto): String {
val consentRequest = consentService.getConsentRequest(consentDto.consentChallenge)
return if (consentDto.consent) {
consentService.accept(consentRequest)
} else {
consentService.reject(consentRequest)
}.toString()
}
@Error(exception = ApiException::class)
fun onHydraApiException(exception: ApiException): HttpResponse<String> {
return when (exception.code) {
401, 404 -> HttpResponse.unauthorized<String>().body("INVALID_CONSENT_CHALLENGE")
410 -> HttpResponse.unauthorized<String>().body("CONSENT_REQUEST_USED")
else -> HttpResponse.status<Unit>(HttpStatus.valueOf(exception.code.takeIf { it != 0 } ?: 500))
.body("HYDRA")
}
}
}
@@ -0,0 +1,100 @@
package net.trueog.staffauth.controller.oauth
import io.grpc.Status
import io.grpc.StatusException
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.HttpStatus
import io.micronaut.http.annotation.*
import io.micronaut.http.client.exceptions.HttpClientResponseException
import io.micronaut.security.annotation.Secured
import io.micronaut.security.rules.SecurityRule
import net.trueog.staffauth.dto.login.CredentialsDto
import net.trueog.staffauth.dto.login.MinecraftCheckDto
import net.trueog.staffauth.dto.login.TotpDto
import net.trueog.staffauth.exception.IncorrectTotpCodeException
import net.trueog.staffauth.exception.TooManyRequestsException
import net.trueog.staffauth.exception.login.IncorrectLoginStageException
import net.trueog.staffauth.exception.login.IncorrectUsernameOrPasswordException
import net.trueog.staffauth.exception.login.InvalidLoginChallengeException
import net.trueog.staffauth.model.LoginStage
import net.trueog.staffauth.service.oauth.LoginService
import sh.ory.hydra.ApiException
@Controller("/login")
@Secured(SecurityRule.IS_ANONYMOUS)
class LoginController(
private val loginService: LoginService,
) {
@Get("/currentStage")
fun currentStage(@QueryValue("login_challenge") loginChallenge: String): String =
loginService.getCurrentStage(loginChallenge)
@Post("/credentials")
suspend fun credentials(@Body credentialsDto: CredentialsDto) {
loginService.usernamePassword(credentialsDto.loginChallenge, credentialsDto.username, credentialsDto.password)
}
@Post("/minecraftcheck")
suspend fun minecraftCheck(@Body minecraftCheckDto: MinecraftCheckDto, request: HttpRequest<*>): Boolean {
return loginService.minecraftCheck(minecraftCheckDto.loginChallenge, request.remoteAddress.address.hostAddress)
}
@Post("/totp")
suspend fun totp(@Body totpDto: TotpDto): String {
loginService.totp(totpDto.loginChallenge, totpDto.code)
val redirectUri = loginService.accept(totpDto.loginChallenge)
return redirectUri
}
@Error(exception = IncorrectUsernameOrPasswordException::class)
fun onIncorrectUsernameOrPassword(): HttpResponse<String> =
HttpResponse.unauthorized<String>().body("INCORRECT_USERNAME_OR_PASSWORD")
@Error(exception = IncorrectTotpCodeException::class)
fun onIncorrectTotpCode(): HttpResponse<String> = HttpResponse.unauthorized<String>().body("INCORRECT_TOTP_CODE")
@Error(exception = IncorrectLoginStageException::class)
fun onIncorrectLoginStage(exception: IncorrectLoginStageException): HttpResponse<String> =
HttpResponse.status<Unit>(HttpStatus.CONFLICT).body(
when (exception.correctLoginStage) {
is LoginStage.AwaitingMinecraftCheck -> "MINECRAFT_CHECK"
is LoginStage.AwaitingTotp -> "TOTP"
is LoginStage.AwaitingAccept -> "ACCEPT"
}
)
@Error(exception = InvalidLoginChallengeException::class)
fun onInvalidLoginChallenge(): HttpResponse<String> =
HttpResponse.unauthorized<String>().body("INVALID_LOGIN_CHALLENGE")
@Error(exception = StatusException::class)
fun onGrpcStatusException(exception: StatusException): HttpResponse<String> {
if (exception.status.code != Status.Code.DEADLINE_EXCEEDED) {
if (exception.status.code == Status.Code.UNAVAILABLE) {
return HttpResponse.status<Unit>(HttpStatus.SERVICE_UNAVAILABLE).body("MINECRAFT_CHECK_UNAVAILABLE")
}
throw exception
}
return HttpResponse.status<Unit>(HttpStatus.GATEWAY_TIMEOUT).body("MINECRAFT_CHECK_TIMEOUT")
}
@Error(exception = ApiException::class)
fun onHydraApiException(exception: ApiException): HttpResponse<String> {
return when (exception.code) {
401, 404 -> HttpResponse.unauthorized<String>().body("INVALID_LOGIN_CHALLENGE")
410 -> HttpResponse.unauthorized<String>().body("LOGIN_REQUEST_USED")
else -> HttpResponse.status<Unit>(HttpStatus.valueOf(exception.code.takeIf { it != 0 } ?: 500))
.body("HYDRA")
}
}
@Error(exception = HttpClientResponseException::class)
fun onHttpClientResponseException(exception: HttpClientResponseException) {
when (exception.status) {
HttpStatus.NO_CONTENT, HttpStatus.BAD_REQUEST -> throw IncorrectUsernameOrPasswordException()
HttpStatus.TOO_MANY_REQUESTS -> throw TooManyRequestsException()
else -> throw exception
}
}
}
@@ -0,0 +1,6 @@
package net.trueog.staffauth.dto.admin
import io.micronaut.serde.annotation.Serdeable
@Serdeable
data class CreateInviteDto(val invitedUserId: Long)
@@ -0,0 +1,11 @@
package net.trueog.staffauth.dto.admin
import io.micronaut.serde.annotation.Serdeable
import net.trueog.staffauth.entity.User
import net.trueog.staffauth.model.Role
import java.util.*
@Serdeable
data class CreateUserDto(val email: String, val role: Role, val minecraftUuid: UUID) {
fun toEntity() = User(email, role, minecraftUuid, null, null, false)
}
@@ -0,0 +1,15 @@
package net.trueog.staffauth.dto.admin
import io.micronaut.serde.annotation.Serdeable
import net.trueog.staffauth.entity.Invite
@Serdeable
data class InviteDto(val id: Long, val token: String, val invitedUserId: Long) {
companion object {
fun fromEntity(entity: Invite) = InviteDto(
entity.id!!,
entity.token,
entity.invitedUserId
)
}
}
@@ -0,0 +1,14 @@
package net.trueog.staffauth.dto.admin
import io.micronaut.serde.annotation.Serdeable
import net.trueog.staffauth.model.Role
import java.util.*
@Serdeable
data class UpdateUserDto(
val id: Long,
val email: String?,
val role: Role?,
val minecraftUuid: UUID?,
val deactivated: Boolean?
)
@@ -0,0 +1,29 @@
package net.trueog.staffauth.dto.admin
import io.micronaut.serde.annotation.Serdeable
import net.trueog.staffauth.entity.User
import net.trueog.staffauth.model.Role
import java.util.*
@Serdeable
data class UserDto(
val id: Long,
val username: String,
val email: String?,
val role: Role,
val minecraftUuid: UUID,
val deactivated: Boolean,
val setUp: Boolean
) {
companion object {
fun fromEntity(entity: User, username: String) = UserDto(
entity.id!!,
username,
entity.email,
entity.role,
entity.minecraftUuid,
entity.deactivated,
entity.isSetUp
)
}
}
@@ -0,0 +1,6 @@
package net.trueog.staffauth.dto.consent
import io.micronaut.serde.annotation.Serdeable
@Serdeable
data class ConsentDto(val consent: Boolean, val consentChallenge: String)
@@ -0,0 +1,6 @@
package net.trueog.staffauth.dto.login
import io.micronaut.serde.annotation.Serdeable
@Serdeable
data class CredentialsDto(val username: String, val password: String, val loginChallenge: String)
@@ -0,0 +1,6 @@
package net.trueog.staffauth.dto.login
import io.micronaut.serde.annotation.Serdeable
@Serdeable
data class MinecraftCheckDto(val loginChallenge: String)
@@ -0,0 +1,6 @@
package net.trueog.staffauth.dto.login
import io.micronaut.serde.annotation.Serdeable
@Serdeable
data class TotpDto(val code: String, val loginChallenge: String)
@@ -0,0 +1,13 @@
package net.trueog.staffauth.dto.setup
import io.micronaut.core.annotation.Introspected
import io.micronaut.serde.annotation.Serdeable
import jakarta.validation.constraints.NotBlank
@Introspected
@Serdeable
data class DetailsDto(
@param:NotBlank val email: String,
@param:NotBlank val password: String,
@param:NotBlank val token: String
)
@@ -0,0 +1,6 @@
package net.trueog.staffauth.dto.setup
import io.micronaut.serde.annotation.Serdeable
@Serdeable
data class TokenDto(val token: String)
@@ -0,0 +1,6 @@
package net.trueog.staffauth.dto.setup
import io.micronaut.serde.annotation.Serdeable
@Serdeable
data class TotpDto(val secret: String, val qrCode: String)
@@ -0,0 +1,6 @@
package net.trueog.staffauth.dto.setup
import io.micronaut.serde.annotation.Serdeable
@Serdeable
data class TotpVerifyDto(val token: String, val code: String)
@@ -0,0 +1,16 @@
package net.trueog.staffauth.entity
import io.micronaut.data.annotation.GeneratedValue
import io.micronaut.data.annotation.Id
import io.micronaut.data.annotation.MappedEntity
import io.micronaut.serde.annotation.Serdeable
@Serdeable
@MappedEntity("invites")
data class Invite(
var token: String,
var invitedUserId: Long,
@field:Id
@field:GeneratedValue(GeneratedValue.Type.AUTO)
var id: Long? = null
)
@@ -0,0 +1,25 @@
package net.trueog.staffauth.entity
import io.micronaut.data.annotation.*
import io.micronaut.data.model.DataType
import io.micronaut.serde.annotation.Serdeable
import net.trueog.staffauth.model.Role
import java.util.*
@Serdeable
@MappedEntity("users")
data class User(
var email: String?,
@field:TypeDef(type = DataType.STRING)
var role: Role,
var minecraftUuid: UUID,
var passwordHash: String?,
var totpSecret: String?,
var deactivated: Boolean,
@field:Id
@field:GeneratedValue(GeneratedValue.Type.AUTO)
var id: Long? = null
) {
@Transient
val isSetUp = this.email != null && this.passwordHash != null && this.totpSecret != null
}
@@ -0,0 +1,3 @@
package net.trueog.staffauth.exception
class IncorrectTotpCodeException : RuntimeException()
@@ -0,0 +1,3 @@
package net.trueog.staffauth.exception
class TooManyRequestsException : RuntimeException()
@@ -0,0 +1,13 @@
package net.trueog.staffauth.exception
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.HttpStatus
import io.micronaut.http.server.exceptions.ExceptionHandler
import jakarta.inject.Singleton
@Singleton
class TooManyRequestsExceptionHandler : ExceptionHandler<TooManyRequestsException, HttpResponse<*>> {
override fun handle(request: HttpRequest<*>, exception: TooManyRequestsException) =
HttpResponse.status<Unit>(HttpStatus.TOO_MANY_REQUESTS)!!
}
@@ -0,0 +1,3 @@
package net.trueog.staffauth.exception.invite
class DuplicateInviteException : RuntimeException()
@@ -0,0 +1,13 @@
package net.trueog.staffauth.exception.invite
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.HttpStatus
import io.micronaut.http.server.exceptions.ExceptionHandler
import jakarta.inject.Singleton
@Singleton
class DuplicateInviteExceptionHandler : ExceptionHandler<DuplicateInviteException, HttpResponse<*>> {
override fun handle(request: HttpRequest<*>, exception: DuplicateInviteException) =
HttpResponse.status<Unit>(HttpStatus.CONFLICT).body("DUPLICATE_INVITE")!!
}
@@ -0,0 +1,3 @@
package net.trueog.staffauth.exception.invite
class InvalidInviteException : RuntimeException()
@@ -0,0 +1,12 @@
package net.trueog.staffauth.exception.invite
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.server.exceptions.ExceptionHandler
import jakarta.inject.Singleton
@Singleton
class InvalidInviteExceptionHandler : ExceptionHandler<InvalidInviteException, HttpResponse<*>> {
override fun handle(request: HttpRequest<*>, exception: InvalidInviteException) =
HttpResponse.unauthorized<String>().body("INVALID_INVITE")!!
}
@@ -0,0 +1,3 @@
package net.trueog.staffauth.exception.invite
class UserAlreadySetUpException : RuntimeException()
@@ -0,0 +1,12 @@
package net.trueog.staffauth.exception.invite
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.server.exceptions.ExceptionHandler
import jakarta.inject.Singleton
@Singleton
class UserAlreadySetUpExceptionHandler : ExceptionHandler<UserAlreadySetUpException, HttpResponse<*>> {
override fun handle(request: HttpRequest<*>, exception: UserAlreadySetUpException) =
HttpResponse.badRequest("USER_ALREADY_SET_UP")!!
}
@@ -0,0 +1,5 @@
package net.trueog.staffauth.exception.login
import net.trueog.staffauth.model.LoginStage
class IncorrectLoginStageException(val correctLoginStage: LoginStage) : RuntimeException()
@@ -0,0 +1,3 @@
package net.trueog.staffauth.exception.login
class IncorrectUsernameOrPasswordException : RuntimeException()
@@ -0,0 +1,3 @@
package net.trueog.staffauth.exception.login
class InvalidLoginChallengeException : RuntimeException()
@@ -0,0 +1,3 @@
package net.trueog.staffauth.exception.setup
class DeactivatedException : RuntimeException()
@@ -0,0 +1,5 @@
package net.trueog.staffauth.exception.setup
import net.trueog.staffauth.model.SetupStage
class IncorrectSetupStageException(val correctSetupStage: SetupStage) : RuntimeException()
@@ -0,0 +1,12 @@
package net.trueog.staffauth.exception.setup
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.server.exceptions.ExceptionHandler
import jakarta.inject.Singleton
@Singleton
class IncorrectSetupStageExceptionHandler : ExceptionHandler<IncorrectSetupStageException, HttpResponse<*>> {
override fun handle(request: HttpRequest<*>, exception: IncorrectSetupStageException) =
HttpResponse.badRequest("INCORRECT_SETUP_STAGE")!!
}
@@ -0,0 +1,3 @@
package net.trueog.staffauth.exception.setup
class InvalidPasswordException : RuntimeException()
@@ -0,0 +1,12 @@
package net.trueog.staffauth.exception.setup
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.server.exceptions.ExceptionHandler
import jakarta.inject.Singleton
@Singleton
class InvalidPasswordExceptionHandler : ExceptionHandler<InvalidPasswordException, HttpResponse<*>> {
override fun handle(request: HttpRequest<*>, exception: InvalidPasswordException) =
HttpResponse.badRequest("INVALID_PASSWORD")!!
}
@@ -0,0 +1,3 @@
package net.trueog.staffauth.exception.setup
class InvalidTotpCodeLengthException : RuntimeException()
@@ -0,0 +1,12 @@
package net.trueog.staffauth.exception.setup
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.server.exceptions.ExceptionHandler
import jakarta.inject.Singleton
@Singleton
class InvalidTotpCodeLengthExceptionHandler : ExceptionHandler<InvalidTotpCodeLengthException, HttpResponse<*>> {
override fun handle(request: HttpRequest<*>, exception: InvalidTotpCodeLengthException) =
HttpResponse.badRequest("INVALID_TOTP_CODE_LENGTH")!!
}
@@ -0,0 +1,3 @@
package net.trueog.staffauth.exception.user
class DeactivateSelfException : RuntimeException()
@@ -0,0 +1,3 @@
package net.trueog.staffauth.exception.user
class DuplicateMinecraftUuidException : RuntimeException()
@@ -0,0 +1,15 @@
package net.trueog.staffauth.exception.user
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.HttpStatus
import io.micronaut.http.server.exceptions.ExceptionHandler
import jakarta.inject.Singleton
@Singleton
class DuplicateMinecraftUuidExceptionHandler : ExceptionHandler<DuplicateMinecraftUuidException, HttpResponse<*>> {
override fun handle(request: HttpRequest<*>, exception: DuplicateMinecraftUuidException) =
HttpResponse.status<Unit>(
HttpStatus.CONFLICT
).body("DUPLICATE_MINECRAFT_UUID")!!
}
@@ -0,0 +1,3 @@
package net.trueog.staffauth.exception.user
class UserNotFoundException : RuntimeException()
@@ -0,0 +1,12 @@
package net.trueog.staffauth.exception.user
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.server.exceptions.ExceptionHandler
import jakarta.inject.Singleton
@Singleton
class UserNotFoundExceptionHandler : ExceptionHandler<UserNotFoundException, HttpResponse<*>> {
override fun handle(request: HttpRequest<*>, exception: UserNotFoundException) =
HttpResponse.notFound("USER_NOT_FOUND")!!
}
@@ -0,0 +1,14 @@
package net.trueog.staffauth.grpc
import io.grpc.ManagedChannel
import io.micronaut.context.annotation.Factory
import io.micronaut.grpc.annotation.GrpcChannel
import jakarta.inject.Singleton
import proto.IpCheckerGrpcKt
@Factory
class GrpcClients {
@Singleton
fun ipCheckStub(@GrpcChannel($$"${plugin.address}") channel: ManagedChannel) =
IpCheckerGrpcKt.IpCheckerCoroutineStub(channel)
}
@@ -0,0 +1,7 @@
package net.trueog.staffauth.model
sealed class LoginStage {
data class AwaitingMinecraftCheck(val userId: Long) : LoginStage()
data class AwaitingTotp(val userId: Long) : LoginStage()
data class AwaitingAccept(val userId: Long) : LoginStage()
}
@@ -0,0 +1,7 @@
package net.trueog.staffauth.model
enum class Role {
HELPER,
MODERATOR,
ADMIN;
}
@@ -0,0 +1,10 @@
package net.trueog.staffauth.model
sealed class SetupStage {
data class AwaitingMinecraftCheck(val userId: Long, val details: Details) : SetupStage()
data class AwaitingTotp(val userId: Long, val details: Details) : SetupStage()
data class AwaitingTotpVerify(val userId: Long, val details: Details, val totpSecret: String) : SetupStage()
data class AwaitingFinalize(val userId: Long, val details: Details, val totpSecret: String) : SetupStage()
data class Details(val email: String, val passwordHash: String)
}
@@ -0,0 +1,13 @@
package net.trueog.staffauth.repository
import io.micronaut.data.model.query.builder.sql.Dialect
import io.micronaut.data.r2dbc.annotation.R2dbcRepository
import io.micronaut.data.repository.kotlin.CoroutineCrudRepository
import net.trueog.staffauth.entity.Invite
@R2dbcRepository(dialect = Dialect.POSTGRES)
abstract class InviteRepository : CoroutineCrudRepository<Invite, Long> {
abstract suspend fun findByToken(token: String): Invite?
abstract suspend fun findByInvitedUserId(invitedUserId: Long): Invite?
abstract suspend fun deleteByToken(token: String): Int
}
@@ -0,0 +1,12 @@
package net.trueog.staffauth.repository
import io.micronaut.data.model.query.builder.sql.Dialect
import io.micronaut.data.r2dbc.annotation.R2dbcRepository
import io.micronaut.data.repository.kotlin.CoroutineCrudRepository
import net.trueog.staffauth.entity.User
import java.util.*
@R2dbcRepository(dialect = Dialect.POSTGRES)
abstract class UserRepository : CoroutineCrudRepository<User, Long> {
abstract suspend fun findByMinecraftUuid(minecraftUuid: UUID): User?
}
@@ -0,0 +1,14 @@
package net.trueog.staffauth.service
import jakarta.inject.Singleton
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
@Singleton
class Argon2idPasswordEncoderService(private val delegate: PasswordEncoder = Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8()) :
PasswordEncoder {
override fun encode(rawPassword: CharSequence?) = delegate.encode(rawPassword)
override fun matches(rawPassword: CharSequence?, encodedPassword: String?) =
delegate.matches(rawPassword, encodedPassword)
}
@@ -0,0 +1,46 @@
package net.trueog.staffauth.service
import jakarta.inject.Singleton
import kotlinx.coroutines.flow.map
import net.trueog.staffauth.dto.admin.CreateInviteDto
import net.trueog.staffauth.dto.admin.InviteDto
import net.trueog.staffauth.entity.Invite
import net.trueog.staffauth.exception.invite.DuplicateInviteException
import net.trueog.staffauth.exception.invite.UserAlreadySetUpException
import net.trueog.staffauth.exception.user.UserNotFoundException
import net.trueog.staffauth.repository.InviteRepository
import net.trueog.staffauth.repository.UserRepository
@Singleton
class InviteService(
private val inviteRepository: InviteRepository,
private val userRepository: UserRepository,
private val tokenGeneratorService: TokenGeneratorService
) {
fun list() = inviteRepository.findAll().map { InviteDto.fromEntity(it) }
suspend fun get(id: Long) = inviteRepository.findById(id)?.let {
InviteDto.fromEntity(it)
}
suspend fun getByToken(token: String) = inviteRepository.findByToken(token)?.let {
InviteDto.fromEntity(it)
}
suspend fun create(createInviteDto: CreateInviteDto): InviteDto {
val user = userRepository.findById(createInviteDto.invitedUserId) ?: throw UserNotFoundException()
if (user.isSetUp) {
throw UserAlreadySetUpException()
}
if (inviteRepository.findByInvitedUserId(user.id!!) != null) throw DuplicateInviteException()
val token = tokenGeneratorService.generateToken(32)
val invite = Invite(
token.toHexString(),
createInviteDto.invitedUserId
)
return InviteDto.fromEntity(inviteRepository.save(invite))
}
suspend fun delete(id: Long) = inviteRepository.deleteById(id)
}
@@ -0,0 +1,156 @@
package net.trueog.staffauth.service
import com.github.benmanes.caffeine.cache.Caffeine
import dev.samstevens.totp.code.CodeVerifier
import dev.samstevens.totp.code.HashingAlgorithm
import dev.samstevens.totp.qr.QrData
import dev.samstevens.totp.qr.ZxingPngQrGenerator
import dev.samstevens.totp.secret.SecretGenerator
import dev.samstevens.totp.util.Utils.getDataUriForImage
import jakarta.inject.Singleton
import net.trueog.staffauth.client.MinecraftClient
import net.trueog.staffauth.dto.setup.DetailsDto
import net.trueog.staffauth.dto.setup.TotpDto
import net.trueog.staffauth.exception.IncorrectTotpCodeException
import net.trueog.staffauth.exception.invite.InvalidInviteException
import net.trueog.staffauth.exception.setup.DeactivatedException
import net.trueog.staffauth.exception.setup.IncorrectSetupStageException
import net.trueog.staffauth.exception.setup.InvalidTotpCodeLengthException
import net.trueog.staffauth.model.SetupStage
import net.trueog.staffauth.repository.InviteRepository
import net.trueog.staffauth.repository.UserRepository
import proto.IpCheckerGrpcKt
import proto.ipCheckRequest
import java.time.Duration
@Singleton
class SetupService(
private val userRepository: UserRepository,
private val inviteRepository: InviteRepository,
private val passwordEncoderService: Argon2idPasswordEncoderService,
private val ipCheckerStub: IpCheckerGrpcKt.IpCheckerCoroutineStub,
private val secretGenerator: SecretGenerator,
private val codeVerifier: CodeVerifier,
private val minecraftClient: MinecraftClient
) {
/** Maps setup token to [SetupStage]. */
val setupStageMap = Caffeine.newBuilder().expireAfterAccess(Duration.ofMinutes(20)).build<String, SetupStage>()
suspend fun getCurrentStage(token: String) = when (setupStageMap.getIfPresent(token)) {
is SetupStage.AwaitingMinecraftCheck -> "MINECRAFT_CHECK"
is SetupStage.AwaitingTotp -> "TOTP"
is SetupStage.AwaitingTotpVerify -> "TOTP"
is SetupStage.AwaitingFinalize -> "FINALIZE"
null -> {
inviteRepository.findByToken(token) ?: throw InvalidInviteException()
"DETAILS"
}
}
suspend fun details(token: String, detailsDto: DetailsDto) {
setupStageMap.getIfPresent(token)?.let {
throw IncorrectSetupStageException(it)
}
val invite = inviteRepository.findByToken(token) ?: throw InvalidInviteException()
val details = SetupStage.AwaitingMinecraftCheck(
invite.invitedUserId, SetupStage.Details(
detailsDto.email,
passwordEncoderService.encode(
detailsDto.password
)!!
)
)
setupStageMap.put(token, details)
}
suspend fun minecraftCheck(token: String, ip: String): Boolean {
val setupStage = setupStageMap.getIfPresent(token) ?: throw InvalidInviteException()
if (setupStage !is SetupStage.AwaitingMinecraftCheck) throw IncorrectSetupStageException(setupStage)
// If the user can't be found, that must mean the user was deleted, and as such, the invite is invalid
val user = userRepository.findById(setupStage.userId) ?: throw InvalidInviteException()
// Don't let user set up their account if the account is deactivated
if (user.deactivated) {
throw DeactivatedException()
}
val reply = ipCheckerStub.checkIp(ipCheckRequest {
this.uuid = user.minecraftUuid.toString()
})
val valid = reply.ip == ip
if (valid) {
setupStageMap.put(token, SetupStage.AwaitingTotp(setupStage.userId, setupStage.details))
}
return valid
}
suspend fun generateTotp(token: String): TotpDto {
val setupStage = setupStageMap.getIfPresent(token) ?: throw InvalidInviteException()
val (secret, userId, passwordHash) = if (setupStage !is SetupStage.AwaitingTotp) {
if (setupStage is SetupStage.AwaitingTotpVerify) {
Triple(setupStage.totpSecret, setupStage.userId, setupStage.details)
} else {
throw IncorrectSetupStageException(setupStage)
}
} else {
Triple(secretGenerator.generate(), setupStage.userId, setupStage.details)
}
// If the user can't be found, that must mean the user was deleted, and as such, the invite is invalid
val minecraftUuid = userRepository.findById(userId)?.minecraftUuid ?: throw InvalidInviteException()
val minecraftProfileDto = minecraftClient.getByUuid(minecraftUuid)
val qrCodeData = QrData.Builder()
.label(minecraftProfileDto?.name ?: throw IllegalStateException())
.secret(secret)
.issuer("Staff-OG")
.algorithm(HashingAlgorithm.SHA1)
.digits(6)
.period(30)
.build()
val generator = ZxingPngQrGenerator();
val imageData = generator.generate(qrCodeData)
val qrCode = getDataUriForImage(imageData, generator.imageMimeType)
setupStageMap.put(token, SetupStage.AwaitingTotpVerify(userId, passwordHash, secret))
return TotpDto(secret, qrCode)
}
fun verifyTotp(token: String, code: String) {
val setupStage = setupStageMap.getIfPresent(token) ?: throw InvalidInviteException()
if (setupStage !is SetupStage.AwaitingTotpVerify) throw IncorrectSetupStageException(setupStage)
if (code.length != 6) {
throw InvalidTotpCodeLengthException()
}
if (!codeVerifier.isValidCode(setupStage.totpSecret, code)) {
throw IncorrectTotpCodeException()
}
setupStageMap.put(
token,
SetupStage.AwaitingFinalize(setupStage.userId, setupStage.details, setupStage.totpSecret)
)
}
suspend fun finalize(token: String) {
val setupStage = setupStageMap.getIfPresent(token) ?: throw InvalidInviteException()
if (setupStage !is SetupStage.AwaitingFinalize) throw IncorrectSetupStageException(setupStage)
// If the user can't be found, that must mean the user was deleted, and as such, the invite is invalid
val user = userRepository.findById(setupStage.userId) ?: throw InvalidInviteException()
val setUpUser = user.copy(
email = setupStage.details.email,
passwordHash = setupStage.details.passwordHash,
totpSecret = setupStage.totpSecret
)
userRepository.update(setUpUser)
inviteRepository.deleteByToken(token)
setupStageMap.invalidate(token)
}
}
@@ -0,0 +1,15 @@
package net.trueog.staffauth.service
import jakarta.inject.Singleton
import java.security.SecureRandom
@Singleton
class TokenGeneratorService {
private val secureRandom = SecureRandom.getInstanceStrong()
fun generateToken(length: Int): ByteArray {
val token = ByteArray(length)
secureRandom.nextBytes(token)
return token
}
}
@@ -0,0 +1,56 @@
package net.trueog.staffauth.service
import io.micronaut.security.authentication.Authentication
import jakarta.inject.Singleton
import kotlinx.coroutines.flow.map
import net.trueog.staffauth.client.MinecraftClient
import net.trueog.staffauth.dto.admin.CreateUserDto
import net.trueog.staffauth.dto.admin.UpdateUserDto
import net.trueog.staffauth.dto.admin.UserDto
import net.trueog.staffauth.exception.user.DeactivateSelfException
import net.trueog.staffauth.exception.user.DuplicateMinecraftUuidException
import net.trueog.staffauth.repository.UserRepository
@Singleton
class UserService(private val userRepository: UserRepository, private val minecraftClient: MinecraftClient) {
fun list() = userRepository.findAll().map {
val username = minecraftClient.getByUuid(it.minecraftUuid)?.name ?: throw IllegalStateException()
UserDto.fromEntity(it, username)
}
suspend fun get(id: Long) = userRepository.findById(id)?.let {
val username = minecraftClient.getByUuid(it.minecraftUuid)?.name ?: throw IllegalStateException()
UserDto.fromEntity(it, username)
}
suspend fun create(createUserDto: CreateUserDto): UserDto {
if (userRepository.findByMinecraftUuid(createUserDto.minecraftUuid) != null) throw DuplicateMinecraftUuidException()
val user = userRepository.save(createUserDto.toEntity())
val username = minecraftClient.getByUuid(user.minecraftUuid)?.name ?: throw IllegalStateException()
return UserDto.fromEntity(user, username)
}
suspend fun update(updateUserDto: UpdateUserDto, auth: Authentication): UserDto? {
val user = userRepository.findById(updateUserDto.id) ?: return null
if (updateUserDto.minecraftUuid != null && userRepository.findByMinecraftUuid(updateUserDto.minecraftUuid)
?.let { it.id != user.id } == true
) throw DuplicateMinecraftUuidException()
if (user.id.toString() == auth.attributes["sub"]) {
throw DeactivateSelfException()
}
val updatedUser = userRepository.update(
user.copy(
email = updateUserDto.email ?: user.email,
role = updateUserDto.role ?: user.role,
minecraftUuid = updateUserDto.minecraftUuid ?: user.minecraftUuid,
deactivated = updateUserDto.deactivated ?: user.deactivated
)
)
val username = minecraftClient.getByUuid(updatedUser.minecraftUuid)?.name ?: throw IllegalStateException()
return UserDto.fromEntity(updatedUser, username)
}
suspend fun delete(id: Long) = userRepository.deleteById(id)
}
@@ -0,0 +1,46 @@
package net.trueog.staffauth.service.oauth
import jakarta.inject.Singleton
import net.trueog.staffauth.client.MinecraftClient
import net.trueog.staffauth.repository.UserRepository
import sh.ory.hydra.api.OAuth2Api
import sh.ory.hydra.model.AcceptOAuth2ConsentRequest
import sh.ory.hydra.model.AcceptOAuth2ConsentRequestSession
import sh.ory.hydra.model.OAuth2ConsentRequest
import sh.ory.hydra.model.RejectOAuth2Request
import java.net.URI
@Singleton
class ConsentService(
private val oAuth2Api: OAuth2Api,
private val userRepository: UserRepository,
private val minecraftClient: MinecraftClient
) {
fun getConsentRequest(consentChallenge: String): OAuth2ConsentRequest {
return oAuth2Api.getOAuth2ConsentRequest(consentChallenge)
}
suspend fun accept(consentRequest: OAuth2ConsentRequest): URI {
val subject = consentRequest.subject ?: throw IllegalStateException()
val user = userRepository.findById(subject.toLong()) ?: throw IllegalStateException()
val claims = buildMap {
if (consentRequest.requestedScope?.contains("email") == true) put("email", user.email)
if (consentRequest.requestedScope?.contains("roles") == true) put("roles", arrayOf(user.role))
if (consentRequest.requestedScope?.contains("profile") == true) {
put("name", minecraftClient.getByUuid(user.minecraftUuid)?.name ?: throw IllegalStateException())
put("picture", "https://minotar.net/helm/${user.minecraftUuid.toString().replace("-", "")}.png")
}
}
val response = oAuth2Api.acceptOAuth2ConsentRequest(
consentRequest.challenge, AcceptOAuth2ConsentRequest().grantScope(consentRequest.requestedScope).session(
AcceptOAuth2ConsentRequestSession().idToken(claims)
)
)
return URI.create(response.redirectTo)
}
fun reject(consentRequest: OAuth2ConsentRequest): URI {
val response = oAuth2Api.rejectOAuth2ConsentRequest(consentRequest.challenge, RejectOAuth2Request())
return URI.create(response.redirectTo)
}
}
@@ -0,0 +1,124 @@
package net.trueog.staffauth.service.oauth
import com.github.benmanes.caffeine.cache.Caffeine
import dev.samstevens.totp.code.CodeVerifier
import jakarta.inject.Singleton
import net.trueog.staffauth.client.MinecraftClient
import net.trueog.staffauth.exception.IncorrectTotpCodeException
import net.trueog.staffauth.exception.login.IncorrectLoginStageException
import net.trueog.staffauth.exception.login.IncorrectUsernameOrPasswordException
import net.trueog.staffauth.exception.login.InvalidLoginChallengeException
import net.trueog.staffauth.model.LoginStage
import net.trueog.staffauth.repository.UserRepository
import net.trueog.staffauth.service.Argon2idPasswordEncoderService
import proto.IpCheckerGrpcKt
import proto.ipCheckRequest
import sh.ory.hydra.ApiException
import sh.ory.hydra.api.OAuth2Api
import sh.ory.hydra.model.AcceptOAuth2LoginRequest
import java.time.Duration
@Singleton
class LoginService(
private val oAuth2Api: OAuth2Api,
private val userRepository: UserRepository,
private val minecraftClient: MinecraftClient,
private val passwordEncoderService: Argon2idPasswordEncoderService,
private val ipCheckerStub: IpCheckerGrpcKt.IpCheckerCoroutineStub,
private val codeVerifier: CodeVerifier
) {
val loginStageMap = Caffeine.newBuilder()
.expireAfterAccess(Duration.ofMinutes(5))
.build<String, LoginStage>()
val totpRegex = "^\\d{6}$".toRegex()
fun getCurrentStage(loginChallenge: String) = when (loginStageMap.getIfPresent(loginChallenge)) {
is LoginStage.AwaitingMinecraftCheck -> "MINECRAFT_CHECK"
is LoginStage.AwaitingTotp -> "TOTP"
is LoginStage.AwaitingAccept -> "ACCEPT"
null -> {
try {
oAuth2Api.getOAuth2LoginRequest(loginChallenge)
"CREDENTIALS"
} catch (_: ApiException) {
throw InvalidLoginChallengeException()
}
}
}
suspend fun usernamePassword(loginChallenge: String, username: String, password: String) {
oAuth2Api.getOAuth2LoginRequest(loginChallenge) // Throws sh.ory.hydra.ApiException if invalid
loginStageMap.getIfPresent(loginChallenge)?.let {
throw IncorrectLoginStageException(it)
}
val minecraftProfileDto =
minecraftClient.getByUsername(username) ?: throw IncorrectUsernameOrPasswordException()
val user =
userRepository.findByMinecraftUuid(minecraftProfileDto.uuid) ?: throw IncorrectUsernameOrPasswordException()
// Don't let user log in if the account is not yet set up, or is deactivated
if (!user.isSetUp || user.deactivated) {
throw IncorrectUsernameOrPasswordException()
}
val userId = user.id ?: throw IncorrectUsernameOrPasswordException()
if (!passwordEncoderService.matches(password, user.passwordHash)) {
throw IncorrectUsernameOrPasswordException()
}
loginStageMap.put(loginChallenge, LoginStage.AwaitingMinecraftCheck(userId))
}
suspend fun minecraftCheck(loginChallenge: String, ip: String): Boolean {
oAuth2Api.getOAuth2LoginRequest(loginChallenge) // Throws sh.ory.hydra.ApiException if invalid
val loginStage = loginStageMap.getIfPresent(loginChallenge) ?: throw InvalidLoginChallengeException()
if (loginStage !is LoginStage.AwaitingMinecraftCheck) throw IncorrectLoginStageException(loginStage)
val uuid = userRepository.findById(loginStage.userId)?.minecraftUuid ?: throw IllegalStateException()
val reply = ipCheckerStub.checkIp(ipCheckRequest {
this.uuid = uuid.toString()
})
val valid = reply.ip == ip
if (valid) {
loginStageMap.put(loginChallenge, LoginStage.AwaitingTotp(loginStage.userId))
}
return valid
}
suspend fun totp(loginChallenge: String, code: String) {
oAuth2Api.getOAuth2LoginRequest(loginChallenge) // Throws sh.ory.hydra.ApiException if invalid
val loginStage = loginStageMap.getIfPresent(loginChallenge) ?: throw InvalidLoginChallengeException()
if (loginStage !is LoginStage.AwaitingTotp) throw IncorrectLoginStageException(loginStage)
if (!totpRegex.matches(code)) {
throw IncorrectTotpCodeException()
}
val totpSecret = userRepository.findById(loginStage.userId)?.totpSecret ?: throw IllegalStateException()
if (!codeVerifier.isValidCode(totpSecret, code)) {
throw IncorrectTotpCodeException()
}
loginStageMap.put(loginChallenge, LoginStage.AwaitingAccept(loginStage.userId))
}
suspend fun accept(loginChallenge: String): String {
val loginRequest =
oAuth2Api.getOAuth2LoginRequest(loginChallenge) // Throws sh.ory.hydra.ApiException if invalid
val loginStage = loginStageMap.getIfPresent(loginChallenge) ?: throw InvalidLoginChallengeException()
if (loginStage !is LoginStage.AwaitingAccept) throw IncorrectLoginStageException(loginStage)
userRepository.findById(loginStage.userId)
?: throw IllegalStateException() // Check if user still exists before we authorize
val response = oAuth2Api.acceptOAuth2LoginRequest(
loginRequest.challenge,
AcceptOAuth2LoginRequest().subject(loginStage.userId.toString()).remember(true).rememberFor(3600)
)
loginStageMap.invalidate(loginChallenge);
return response.redirectTo
}
}
+18
View File
@@ -0,0 +1,18 @@
syntax = "proto3";
option java_multiple_files = true;
option java_outer_classname = "IpCheckProto";
package proto;
service IpChecker {
rpc CheckIp (IpCheckRequest) returns (IpCheckReply) {}
}
message IpCheckRequest {
string uuid = 1;
}
message IpCheckReply {
string ip = 1;
}
@@ -0,0 +1,17 @@
CREATE TABLE users
(
id SERIAL PRIMARY KEY,
email TEXT,
role TEXT NOT NULL,
minecraft_uuid UUID UNIQUE NOT NULL,
password_hash TEXT,
totp_secret TEXT,
deactivated BOOLEAN NOT NULL
);
CREATE TABLE invites
(
id SERIAL PRIMARY KEY,
token TEXT UNIQUE NOT NULL,
invited_user_id INTEGER REFERENCES users (id) UNIQUE NOT NULL
);
+15
View File
@@ -0,0 +1,15 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<!-- encoders are assigned the type
ch.qos.logback.classic.encoder.PatternLayoutEncoder by default -->
<encoder>
<pattern>%cyan(%d{HH:mm:ss.SSS}) %gray([%thread]) %highlight(%-5level) %magenta(%logger{36}) - %msg%n
</pattern>
</encoder>
</appender>
<root level="info">
<appender-ref ref="STDOUT"/>
</root>
</configuration>