Initial commit
This commit is contained in:
79 files changed
+1915
No files matched your search
+17
@@ -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.
@@ -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/>
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
micronautVersion=4.10.14
|
||||
kotlinVersion=2.3.21
|
||||
org.gradle.jvmargs=-Xmx4096M
|
||||
Vendored
BIN
Binary file not shown.
+7
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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()
|
||||
+13
@@ -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()
|
||||
+12
@@ -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()
|
||||
+12
@@ -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()
|
||||
+3
@@ -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()
|
||||
+12
@@ -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()
|
||||
+12
@@ -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()
|
||||
+12
@@ -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()
|
||||
+15
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user