init
This commit is contained in:
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/mvnw text eol=lf
|
||||||
|
*.cmd text eol=crlf
|
||||||
19
.mvn/wrapper/maven-wrapper.properties
vendored
Normal file
19
.mvn/wrapper/maven-wrapper.properties
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
# or more contributor license agreements. See the NOTICE file
|
||||||
|
# distributed with this work for additional information
|
||||||
|
# regarding copyright ownership. The ASF licenses this file
|
||||||
|
# to you 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
|
||||||
|
#
|
||||||
|
# http://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.
|
||||||
|
wrapperVersion=3.3.2
|
||||||
|
distributionType=only-script
|
||||||
|
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.10/apache-maven-3.9.10-bin.zip
|
||||||
259
mvnw
vendored
Executable file
259
mvnw
vendored
Executable file
@@ -0,0 +1,259 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
# or more contributor license agreements. See the NOTICE file
|
||||||
|
# distributed with this work for additional information
|
||||||
|
# regarding copyright ownership. The ASF licenses this file
|
||||||
|
# to you 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
|
||||||
|
#
|
||||||
|
# http://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.
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Apache Maven Wrapper startup batch script, version 3.3.2
|
||||||
|
#
|
||||||
|
# Optional ENV vars
|
||||||
|
# -----------------
|
||||||
|
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
|
||||||
|
# MVNW_REPOURL - repo url base for downloading maven distribution
|
||||||
|
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||||
|
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
set -euf
|
||||||
|
[ "${MVNW_VERBOSE-}" != debug ] || set -x
|
||||||
|
|
||||||
|
# OS specific support.
|
||||||
|
native_path() { printf %s\\n "$1"; }
|
||||||
|
case "$(uname)" in
|
||||||
|
CYGWIN* | MINGW*)
|
||||||
|
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
|
||||||
|
native_path() { cygpath --path --windows "$1"; }
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# set JAVACMD and JAVACCMD
|
||||||
|
set_java_home() {
|
||||||
|
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
|
||||||
|
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"
|
||||||
|
JAVACCMD="$JAVA_HOME/jre/sh/javac"
|
||||||
|
else
|
||||||
|
JAVACMD="$JAVA_HOME/bin/java"
|
||||||
|
JAVACCMD="$JAVA_HOME/bin/javac"
|
||||||
|
|
||||||
|
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
|
||||||
|
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
|
||||||
|
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD="$(
|
||||||
|
'set' +e
|
||||||
|
'unset' -f command 2>/dev/null
|
||||||
|
'command' -v java
|
||||||
|
)" || :
|
||||||
|
JAVACCMD="$(
|
||||||
|
'set' +e
|
||||||
|
'unset' -f command 2>/dev/null
|
||||||
|
'command' -v javac
|
||||||
|
)" || :
|
||||||
|
|
||||||
|
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
|
||||||
|
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# hash string like Java String::hashCode
|
||||||
|
hash_string() {
|
||||||
|
str="${1:-}" h=0
|
||||||
|
while [ -n "$str" ]; do
|
||||||
|
char="${str%"${str#?}"}"
|
||||||
|
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
|
||||||
|
str="${str#?}"
|
||||||
|
done
|
||||||
|
printf %x\\n $h
|
||||||
|
}
|
||||||
|
|
||||||
|
verbose() { :; }
|
||||||
|
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
|
||||||
|
|
||||||
|
die() {
|
||||||
|
printf %s\\n "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
trim() {
|
||||||
|
# MWRAPPER-139:
|
||||||
|
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
|
||||||
|
# Needed for removing poorly interpreted newline sequences when running in more
|
||||||
|
# exotic environments such as mingw bash on Windows.
|
||||||
|
printf "%s" "${1}" | tr -d '[:space:]'
|
||||||
|
}
|
||||||
|
|
||||||
|
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
|
||||||
|
while IFS="=" read -r key value; do
|
||||||
|
case "${key-}" in
|
||||||
|
distributionUrl) distributionUrl=$(trim "${value-}") ;;
|
||||||
|
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
|
||||||
|
esac
|
||||||
|
done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties"
|
||||||
|
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties"
|
||||||
|
|
||||||
|
case "${distributionUrl##*/}" in
|
||||||
|
maven-mvnd-*bin.*)
|
||||||
|
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
|
||||||
|
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
|
||||||
|
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
|
||||||
|
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
|
||||||
|
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
|
||||||
|
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
|
||||||
|
*)
|
||||||
|
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
|
||||||
|
distributionPlatform=linux-amd64
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
|
||||||
|
;;
|
||||||
|
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
|
||||||
|
*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||||
|
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||||
|
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
|
||||||
|
distributionUrlName="${distributionUrl##*/}"
|
||||||
|
distributionUrlNameMain="${distributionUrlName%.*}"
|
||||||
|
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
|
||||||
|
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
|
||||||
|
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
|
||||||
|
|
||||||
|
exec_maven() {
|
||||||
|
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
|
||||||
|
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -d "$MAVEN_HOME" ]; then
|
||||||
|
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||||
|
exec_maven "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "${distributionUrl-}" in
|
||||||
|
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
|
||||||
|
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# prepare tmp dir
|
||||||
|
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
|
||||||
|
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
|
||||||
|
trap clean HUP INT TERM EXIT
|
||||||
|
else
|
||||||
|
die "cannot create temp dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p -- "${MAVEN_HOME%/*}"
|
||||||
|
|
||||||
|
# Download and Install Apache Maven
|
||||||
|
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||||
|
verbose "Downloading from: $distributionUrl"
|
||||||
|
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||||
|
|
||||||
|
# select .zip or .tar.gz
|
||||||
|
if ! command -v unzip >/dev/null; then
|
||||||
|
distributionUrl="${distributionUrl%.zip}.tar.gz"
|
||||||
|
distributionUrlName="${distributionUrl##*/}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# verbose opt
|
||||||
|
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
|
||||||
|
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
|
||||||
|
|
||||||
|
# normalize http auth
|
||||||
|
case "${MVNW_PASSWORD:+has-password}" in
|
||||||
|
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||||
|
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
|
||||||
|
verbose "Found wget ... using wget"
|
||||||
|
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
|
||||||
|
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
|
||||||
|
verbose "Found curl ... using curl"
|
||||||
|
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
|
||||||
|
elif set_java_home; then
|
||||||
|
verbose "Falling back to use Java to download"
|
||||||
|
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
|
||||||
|
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||||
|
cat >"$javaSource" <<-END
|
||||||
|
public class Downloader extends java.net.Authenticator
|
||||||
|
{
|
||||||
|
protected java.net.PasswordAuthentication getPasswordAuthentication()
|
||||||
|
{
|
||||||
|
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
|
||||||
|
}
|
||||||
|
public static void main( String[] args ) throws Exception
|
||||||
|
{
|
||||||
|
setDefault( new Downloader() );
|
||||||
|
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
END
|
||||||
|
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
|
||||||
|
verbose " - Compiling Downloader.java ..."
|
||||||
|
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
|
||||||
|
verbose " - Running Downloader.java ..."
|
||||||
|
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||||
|
if [ -n "${distributionSha256Sum-}" ]; then
|
||||||
|
distributionSha256Result=false
|
||||||
|
if [ "$MVN_CMD" = mvnd.sh ]; then
|
||||||
|
echo "Checksum validation is not supported for maven-mvnd." >&2
|
||||||
|
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||||
|
exit 1
|
||||||
|
elif command -v sha256sum >/dev/null; then
|
||||||
|
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then
|
||||||
|
distributionSha256Result=true
|
||||||
|
fi
|
||||||
|
elif command -v shasum >/dev/null; then
|
||||||
|
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
|
||||||
|
distributionSha256Result=true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
|
||||||
|
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ $distributionSha256Result = false ]; then
|
||||||
|
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
|
||||||
|
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# unzip and move
|
||||||
|
if command -v unzip >/dev/null; then
|
||||||
|
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
|
||||||
|
else
|
||||||
|
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
|
||||||
|
fi
|
||||||
|
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url"
|
||||||
|
mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
|
||||||
|
|
||||||
|
clean || :
|
||||||
|
exec_maven "$@"
|
||||||
149
mvnw.cmd
vendored
Normal file
149
mvnw.cmd
vendored
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<# : batch portion
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
@REM Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
@REM or more contributor license agreements. See the NOTICE file
|
||||||
|
@REM distributed with this work for additional information
|
||||||
|
@REM regarding copyright ownership. The ASF licenses this file
|
||||||
|
@REM to you under the Apache License, Version 2.0 (the
|
||||||
|
@REM "License"); you may not use this file except in compliance
|
||||||
|
@REM with the License. You may obtain a copy of the License at
|
||||||
|
@REM
|
||||||
|
@REM http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@REM
|
||||||
|
@REM Unless required by applicable law or agreed to in writing,
|
||||||
|
@REM software distributed under the License is distributed on an
|
||||||
|
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
@REM KIND, either express or implied. See the License for the
|
||||||
|
@REM specific language governing permissions and limitations
|
||||||
|
@REM under the License.
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
@REM Apache Maven Wrapper startup batch script, version 3.3.2
|
||||||
|
@REM
|
||||||
|
@REM Optional ENV vars
|
||||||
|
@REM MVNW_REPOURL - repo url base for downloading maven distribution
|
||||||
|
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||||
|
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
|
||||||
|
@SET __MVNW_CMD__=
|
||||||
|
@SET __MVNW_ERROR__=
|
||||||
|
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
|
||||||
|
@SET PSModulePath=
|
||||||
|
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
|
||||||
|
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
|
||||||
|
)
|
||||||
|
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
|
||||||
|
@SET __MVNW_PSMODULEP_SAVE=
|
||||||
|
@SET __MVNW_ARG0_NAME__=
|
||||||
|
@SET MVNW_USERNAME=
|
||||||
|
@SET MVNW_PASSWORD=
|
||||||
|
@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*)
|
||||||
|
@echo Cannot start maven from wrapper >&2 && exit /b 1
|
||||||
|
@GOTO :EOF
|
||||||
|
: end batch / begin powershell #>
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
if ($env:MVNW_VERBOSE -eq "true") {
|
||||||
|
$VerbosePreference = "Continue"
|
||||||
|
}
|
||||||
|
|
||||||
|
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
|
||||||
|
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
|
||||||
|
if (!$distributionUrl) {
|
||||||
|
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
|
||||||
|
"maven-mvnd-*" {
|
||||||
|
$USE_MVND = $true
|
||||||
|
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
|
||||||
|
$MVN_CMD = "mvnd.cmd"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default {
|
||||||
|
$USE_MVND = $false
|
||||||
|
$MVN_CMD = $script -replace '^mvnw','mvn'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||||
|
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||||
|
if ($env:MVNW_REPOURL) {
|
||||||
|
$MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" }
|
||||||
|
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')"
|
||||||
|
}
|
||||||
|
$distributionUrlName = $distributionUrl -replace '^.*/',''
|
||||||
|
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
|
||||||
|
$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain"
|
||||||
|
if ($env:MAVEN_USER_HOME) {
|
||||||
|
$MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain"
|
||||||
|
}
|
||||||
|
$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
|
||||||
|
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
|
||||||
|
|
||||||
|
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
|
||||||
|
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||||
|
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||||
|
exit $?
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
|
||||||
|
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
|
||||||
|
}
|
||||||
|
|
||||||
|
# prepare tmp dir
|
||||||
|
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
|
||||||
|
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
|
||||||
|
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
|
||||||
|
trap {
|
||||||
|
if ($TMP_DOWNLOAD_DIR.Exists) {
|
||||||
|
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||||
|
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
|
||||||
|
|
||||||
|
# Download and Install Apache Maven
|
||||||
|
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||||
|
Write-Verbose "Downloading from: $distributionUrl"
|
||||||
|
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||||
|
|
||||||
|
$webclient = New-Object System.Net.WebClient
|
||||||
|
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
|
||||||
|
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
|
||||||
|
}
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
|
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
|
||||||
|
|
||||||
|
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||||
|
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
|
||||||
|
if ($distributionSha256Sum) {
|
||||||
|
if ($USE_MVND) {
|
||||||
|
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
|
||||||
|
}
|
||||||
|
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
|
||||||
|
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
|
||||||
|
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# unzip and move
|
||||||
|
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
|
||||||
|
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null
|
||||||
|
try {
|
||||||
|
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
|
||||||
|
} catch {
|
||||||
|
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
|
||||||
|
Write-Error "fail to move MAVEN_HOME"
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||||
|
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||||
154
pom.xml
Normal file
154
pom.xml
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>3.5.3</version>
|
||||||
|
<relativePath/> <!-- lookup parent from repository -->
|
||||||
|
</parent>
|
||||||
|
<groupId>com.arrokoth</groupId>
|
||||||
|
<artifactId>authorization-server-standalone</artifactId>
|
||||||
|
<version>0.0.1-SNAPSHOT</version>
|
||||||
|
<name>authorization-server-standalone</name>
|
||||||
|
<description>authorization-server-standalone</description>
|
||||||
|
<url/>
|
||||||
|
<licenses>
|
||||||
|
<license/>
|
||||||
|
</licenses>
|
||||||
|
<developers>
|
||||||
|
<developer/>
|
||||||
|
</developers>
|
||||||
|
<scm>
|
||||||
|
<connection/>
|
||||||
|
<developerConnection/>
|
||||||
|
<tag/>
|
||||||
|
<url/>
|
||||||
|
</scm>
|
||||||
|
<properties>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||||
|
<maven.compiler.encoding>UTF-8</maven.compiler.encoding>
|
||||||
|
<maven.compiler.source>17</maven.compiler.source>
|
||||||
|
<maven.compiler.target>17</maven.compiler.target>
|
||||||
|
<spring.boot.version>3.2.0</spring.boot.version>
|
||||||
|
<slf4j.version>2.0.9</slf4j.version>
|
||||||
|
</properties>
|
||||||
|
<dependencies>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-thymeleaf</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.hutool</groupId>
|
||||||
|
<artifactId>hutool-all</artifactId>
|
||||||
|
<version>5.8.39</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Apache Commons Pool2 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.commons</groupId>
|
||||||
|
<artifactId>commons-pool2</artifactId>
|
||||||
|
<version>2.11.1</version> <!-- 根据需要选择最新版本 -->
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.github.xiaoymin</groupId>
|
||||||
|
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
|
||||||
|
<version>4.4.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Session Data Redis -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.session</groupId>
|
||||||
|
<artifactId>spring-session-data-redis</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Data Redis -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<excludes>
|
||||||
|
<exclude>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
</exclude>
|
||||||
|
</excludes>
|
||||||
|
</configuration>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<goals>
|
||||||
|
<goal>build-info</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
<!--Compiler-->
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<version>3.11.0</version>
|
||||||
|
<configuration>
|
||||||
|
<parameters>true</parameters>
|
||||||
|
<source>17</source>
|
||||||
|
<target>17</target>
|
||||||
|
<fork>true</fork>
|
||||||
|
<verbose>true</verbose>
|
||||||
|
<encoding>UTF-8</encoding>
|
||||||
|
<showWarnings>false</showWarnings>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<!-- JUnit 5 requires Surefire version 2.22.0 or higher -->
|
||||||
|
<version>2.22.0</version>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.arrokoth.standalone.authorization;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
public class StandaloneServerApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(StandaloneServerApplication.class, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.arrokoth.standalone.authorization.config;
|
||||||
|
|
||||||
|
import com.arrokoth.standalone.authorization.properties.AuthorizationServerProperties;
|
||||||
|
import com.arrokoth.standalone.authorization.properties.SecurityWebProperties;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.security.config.Customizer;
|
||||||
|
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
|
||||||
|
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
|
||||||
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
|
||||||
|
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSecurity
|
||||||
|
@AllArgsConstructor
|
||||||
|
@EnableMethodSecurity
|
||||||
|
@EnableConfigurationProperties(AuthorizationServerProperties.class)
|
||||||
|
public class AuthorizationServerAutoConfigurer {
|
||||||
|
|
||||||
|
private AuthorizationServerProperties authorizationServerProperties;
|
||||||
|
private SecurityWebProperties securityWebProperties;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置端点的过滤器链
|
||||||
|
*
|
||||||
|
* @param http spring security核心配置类
|
||||||
|
* @return 过滤器链
|
||||||
|
* @throws Exception 抛出
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
|
||||||
|
// 1. 配置 OAuth2 Authorization Server 端点
|
||||||
|
OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = OAuth2AuthorizationServerConfigurer.authorizationServer();
|
||||||
|
// 配置授权确认页面路径
|
||||||
|
authorizationServerConfigurer.authorizationEndpoint(
|
||||||
|
authorizationEndpoint -> authorizationEndpoint.consentPage(authorizationServerProperties.getConsentPage()));
|
||||||
|
|
||||||
|
// 开始构建 HTTP 安全配置
|
||||||
|
http
|
||||||
|
.csrf(AbstractHttpConfigurer::disable) // 暂时禁用 CSRF 保护(可根据需要启用)
|
||||||
|
// 仅匹配 OAuth2 授权服务器端点(如 /oauth2/authorize, /token 等)
|
||||||
|
.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
|
||||||
|
// 请求授权规则:所有匹配该过滤链的请求都必须经过身份验证
|
||||||
|
.authorizeHttpRequests((authorize) -> authorize
|
||||||
|
.anyRequest().authenticated()
|
||||||
|
)
|
||||||
|
// 配置 OAuth2 授权服务器核心功能,启用 OpenID Connect 支持
|
||||||
|
.with(authorizationServerConfigurer, (authorizationServer) ->
|
||||||
|
authorizationServer.oidc(Customizer.withDefaults())
|
||||||
|
);
|
||||||
|
// 2. 异常处理:未登录访问认证端点时跳转到登录页
|
||||||
|
http.exceptionHandling(exceptions -> exceptions
|
||||||
|
.defaultAuthenticationEntryPointFor(
|
||||||
|
new LoginUrlAuthenticationEntryPoint("/login"),
|
||||||
|
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Resource Server 配置(用于支持 UserInfo 和 ClientRegistration 端点)
|
||||||
|
http.oauth2ResourceServer(oauth2 -> oauth2
|
||||||
|
.jwt(Customizer.withDefaults())
|
||||||
|
);
|
||||||
|
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public AuthorizationServerSettings authorizationServerSettings() {
|
||||||
|
return AuthorizationServerSettings.builder()
|
||||||
|
.authorizationEndpoint(authorizationServerProperties.getAuthorizationEndpoint()) // 授权端点路径
|
||||||
|
.issuer(authorizationServerProperties.getIssuer()) // 授权服务器的唯一标识符(Issuer)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public PasswordEncoder passwordEncoder() {
|
||||||
|
return new BCryptPasswordEncoder(); // 仅用于演示,生产环境请使用BCryptPasswordEncoder
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.arrokoth.standalone.authorization.config;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
import org.springframework.web.filter.CorsFilter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CorsConfig 是一个 Spring 配置类。
|
||||||
|
* 用于全局配置跨域资源共享(CORS),允许前端应用访问后端 API。
|
||||||
|
*
|
||||||
|
* 此配置适用于前后端分离架构中常见的跨域请求问题。
|
||||||
|
*/
|
||||||
|
@Slf4j // 使用 Lombok 提供的日志记录器
|
||||||
|
@Configuration // 标记为 Spring 配置类
|
||||||
|
public class CorsConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认允许的跨域源(Origin):所有域名都允许访问。
|
||||||
|
*/
|
||||||
|
private static final String DEFAULT_ALLOWED_ORIGINS = "*";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认允许的请求头(Headers):接受所有类型的请求头。
|
||||||
|
*/
|
||||||
|
private static final String DEFAULT_ALLOWED_HEADERS = "*";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认允许的 HTTP 方法:包括 OPTIONS, HEAD, GET, POST, PUT, DELETE, PATCH。
|
||||||
|
*/
|
||||||
|
private static final String DEFAULT_ALLOWED_METHODS = "OPTIONS,HEAD,GET,POST,PUT,DELETE,PATCH";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预检请求(preflight)的最大缓存时间(单位:秒),默认为 3600 秒(1 小时)。
|
||||||
|
*/
|
||||||
|
private static final String DEFAULT_MAX_AGE = "3600";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建并返回一个 CORS 配置对象。
|
||||||
|
* 该方法设置跨域请求的基本策略:
|
||||||
|
* - 允许任何来源
|
||||||
|
* - 允许任何请求头
|
||||||
|
* - 允许指定的 HTTP 方法
|
||||||
|
* - 设置预检请求最大存活时间
|
||||||
|
*
|
||||||
|
* @return CorsConfiguration 实例
|
||||||
|
*/
|
||||||
|
private CorsConfiguration buildConfig() {
|
||||||
|
CorsConfiguration corsConfiguration = new CorsConfiguration();
|
||||||
|
corsConfiguration.addAllowedOrigin(DEFAULT_ALLOWED_ORIGINS); // 允许任意来源
|
||||||
|
corsConfiguration.addAllowedHeader(DEFAULT_ALLOWED_HEADERS); // 允许任意请求头
|
||||||
|
corsConfiguration.addAllowedMethod(DEFAULT_ALLOWED_METHODS); // 允许指定的方法
|
||||||
|
corsConfiguration.setMaxAge(Long.parseLong(DEFAULT_MAX_AGE)); // 设置预检请求缓存时间
|
||||||
|
return corsConfiguration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建并注册一个全局的 CorsFilter Bean。
|
||||||
|
* 该过滤器将上述 CORS 策略应用到所有路径(/**)上。
|
||||||
|
*
|
||||||
|
* @return CorsFilter 实例
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public CorsFilter corsFilter() {
|
||||||
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
|
source.registerCorsConfiguration("/**", buildConfig()); // 对所有路径启用 CORS 配置
|
||||||
|
|
||||||
|
// 记录日志信息
|
||||||
|
log.debug("Initializing CorsFilter , the default allowed origins is `{}`," +
|
||||||
|
" allowed headers is `{}`, allowed methods is `{}`, max age is `{}`",
|
||||||
|
DEFAULT_ALLOWED_ORIGINS, DEFAULT_ALLOWED_HEADERS, DEFAULT_ALLOWED_METHODS, DEFAULT_MAX_AGE);
|
||||||
|
|
||||||
|
return new CorsFilter(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.arrokoth.standalone.authorization.config;
|
||||||
|
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.session.FlushMode;
|
||||||
|
import org.springframework.session.SaveMode;
|
||||||
|
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableRedisHttpSession(
|
||||||
|
maxInactiveIntervalInSeconds = 3600, // 1小时过期
|
||||||
|
redisNamespace = "spring:session", // 自定义命名空间
|
||||||
|
flushMode = FlushMode.IMMEDIATE,
|
||||||
|
saveMode = SaveMode.ALWAYS
|
||||||
|
)
|
||||||
|
public class RedisSessionConfig {
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package com.arrokoth.standalone.authorization.config;
|
||||||
|
|
||||||
|
import com.arrokoth.standalone.authorization.filter.JwtRequestFilter;
|
||||||
|
import com.arrokoth.standalone.authorization.properties.AuthorizationServerProperties;
|
||||||
|
import com.arrokoth.standalone.authorization.properties.SecurityWebProperties;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.security.authentication.AuthenticationManager;
|
||||||
|
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Configuration
|
||||||
|
@AllArgsConstructor
|
||||||
|
@EnableConfigurationProperties(SecurityWebProperties.class)
|
||||||
|
public class SecurityWebAutoConfigurer {
|
||||||
|
|
||||||
|
private final SecurityWebProperties securityWebProperties;
|
||||||
|
private final AuthorizationServerProperties authorizationServerProperties;
|
||||||
|
|
||||||
|
// @Bean
|
||||||
|
// public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
|
||||||
|
// http
|
||||||
|
// .securityMatcher("/**")
|
||||||
|
// .authorizeHttpRequests(auth -> auth
|
||||||
|
// .requestMatchers(HttpMethod.OPTIONS).permitAll()
|
||||||
|
// .requestMatchers(securityWebProperties.getMergedPermittedUrls().toArray(new String[0])).permitAll()
|
||||||
|
// .anyRequest().authenticated()
|
||||||
|
// )
|
||||||
|
// .formLogin(form -> form
|
||||||
|
// .loginPage(authorizationServerProperties.getLoginPage())
|
||||||
|
// .permitAll()
|
||||||
|
// )
|
||||||
|
// .logout(logout -> logout
|
||||||
|
// .logoutSuccessUrl(securityWebProperties.getLogoutSuccessUrl())
|
||||||
|
// .permitAll()
|
||||||
|
// );
|
||||||
|
//
|
||||||
|
// return http.build();
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
private final JwtRequestFilter jwtRequestFilter; // 你的 Token 过滤器
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
|
||||||
|
log.debug("Configuring default security filter chain");
|
||||||
|
http
|
||||||
|
.csrf(AbstractHttpConfigurer::disable) // 前后端分离通常关闭CSRF
|
||||||
|
.cors(cors -> cors.configurationSource(corsConfigurationSource())) // 启用 CORS 并使用自定义配置
|
||||||
|
.sessionManagement(session -> session
|
||||||
|
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) // 无状态Session
|
||||||
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
.requestMatchers(HttpMethod.OPTIONS).permitAll()
|
||||||
|
.requestMatchers(securityWebProperties.getMergedPermittedUrls().toArray(new String[0])).permitAll()
|
||||||
|
.anyRequest().authenticated())
|
||||||
|
.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class); // 插入Token验证Filter
|
||||||
|
|
||||||
|
http.formLogin(form -> form
|
||||||
|
.loginPage(securityWebProperties.getLoginPage())
|
||||||
|
.loginProcessingUrl(securityWebProperties.getLoginProcessingUrl())
|
||||||
|
.permitAll()
|
||||||
|
)
|
||||||
|
.logout(logout -> logout
|
||||||
|
.logoutSuccessUrl(securityWebProperties.getLogoutSuccessUrl())
|
||||||
|
.permitAll()
|
||||||
|
);
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
|
||||||
|
log.info("Configuring authentication manager for authentication configuration");
|
||||||
|
return authenticationConfiguration.getAuthenticationManager();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
|
log.info("Configuring cors configuration source");
|
||||||
|
CorsConfiguration configuration = new CorsConfiguration();
|
||||||
|
configuration.setAllowedOrigins(List.of("*")); // 替换为前端域名
|
||||||
|
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||||
|
configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type", "X-Requested-With", "accept"));
|
||||||
|
configuration.setExposedHeaders(List.of("Authorization")); // 允许前端访问Authorization头
|
||||||
|
configuration.setAllowCredentials(false);
|
||||||
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
|
source.registerCorsConfiguration("/**", configuration);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.arrokoth.standalone.authorization.controller;
|
||||||
|
|
||||||
|
import com.arrokoth.standalone.authorization.domain.request.LoginRequest;
|
||||||
|
import com.arrokoth.standalone.authorization.domain.response.BasicUser;
|
||||||
|
import com.arrokoth.standalone.authorization.domain.response.RestResponse;
|
||||||
|
import com.arrokoth.standalone.authorization.domain.response.Token;
|
||||||
|
import com.arrokoth.standalone.authorization.properties.SecurityWebProperties;
|
||||||
|
import com.arrokoth.standalone.authorization.service.AuthorizationService;
|
||||||
|
import com.arrokoth.standalone.authorization.service.OAuth2ConsentService;
|
||||||
|
import com.arrokoth.standalone.authorization.util.JwtUtils;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.ui.Model;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.security.Principal;
|
||||||
|
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AuthorizationController {
|
||||||
|
|
||||||
|
|
||||||
|
private final AuthorizationService authorizationService;
|
||||||
|
private final OAuth2ConsentService oAuth2ConsentService;
|
||||||
|
|
||||||
|
|
||||||
|
@GetMapping("/login")
|
||||||
|
public String home() {
|
||||||
|
return "login"; // 对应templates/login.html
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(SecurityWebProperties.AXIOS_LOGIN_PROCESSING_URL)
|
||||||
|
@ResponseBody
|
||||||
|
public RestResponse<Token> axiosLogin(@RequestBody LoginRequest loginRequest) {
|
||||||
|
return RestResponse.success(authorizationService.login(loginRequest));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@GetMapping("/user/info")
|
||||||
|
@ResponseBody
|
||||||
|
public RestResponse<BasicUser> getBasicUser() {
|
||||||
|
return RestResponse.success(authorizationService.getBasicUser());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@PostMapping("/logout")
|
||||||
|
@ResponseBody
|
||||||
|
public ResponseEntity<?> logout(HttpServletRequest request) throws Exception {
|
||||||
|
String token = JwtUtils.extractTokenFromHeader(request);
|
||||||
|
authorizationService.logout(JwtUtils.extractJti(token));
|
||||||
|
return ResponseEntity.ok("Logged out successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping(value = "/oauth2/consent")
|
||||||
|
public String consent(Principal principal, Model model,
|
||||||
|
@RequestParam("client_id") String clientId,
|
||||||
|
@RequestParam("scope") String scope,
|
||||||
|
@RequestParam("state") String state,
|
||||||
|
@RequestParam(name = "user_code", required = false) String userCode) {
|
||||||
|
|
||||||
|
OAuth2ConsentService.ConsentResponse viewModel = oAuth2ConsentService.getConsentDetails(principal, clientId, scope, state, userCode);
|
||||||
|
|
||||||
|
// model.addAttribute("clientId", viewModel.clientId());
|
||||||
|
// model.addAttribute("state", viewModel.state());
|
||||||
|
// model.addAttribute("scopes", viewModel.scopes());
|
||||||
|
model.addAttribute("previouslyApprovedScopes", viewModel.previouslyApprovedScopes());
|
||||||
|
// model.addAttribute("principalName", viewModel.principalName());
|
||||||
|
// model.addAttribute("userCode", viewModel.userCode());
|
||||||
|
// model.addAttribute("requestURI", viewModel.requestURI());
|
||||||
|
return "consent";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package com.arrokoth.standalone.authorization.controller;
|
||||||
|
|
||||||
|
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/test")
|
||||||
|
public class TestController {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET 请求测试:无参数,直接返回字符串
|
||||||
|
*/
|
||||||
|
@GetMapping("/hello")
|
||||||
|
public String sayHello() {
|
||||||
|
return "Hello, Spring Boot!";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET 请求测试:带路径参数
|
||||||
|
* 示例 URL: /api/test/user/123
|
||||||
|
*/
|
||||||
|
@GetMapping("/user/{id}")
|
||||||
|
public String getUserById(@PathVariable Long id) {
|
||||||
|
return "User ID: " + id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET 请求测试:带查询参数
|
||||||
|
* 示例 URL: /api/test/greet?name=John
|
||||||
|
*/
|
||||||
|
@GetMapping("/greet")
|
||||||
|
public String greetUser(@RequestParam String name) {
|
||||||
|
return "Hello, " + name + "!";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST 请求测试:接收 JSON 数据并返回 Map 响应
|
||||||
|
*/
|
||||||
|
@PostMapping("/echo")
|
||||||
|
public Map<String, Object> echoData(@RequestBody Map<String, Object> payload) {
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("received", true);
|
||||||
|
response.put("data", payload);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT 请求测试:更新资源
|
||||||
|
* 示例 URL: /api/test/update/456
|
||||||
|
*/
|
||||||
|
@PutMapping("/update/{id}")
|
||||||
|
public String updateResource(@PathVariable String id, @RequestBody Map<String, String> payload) {
|
||||||
|
return "Resource ID " + id + " updated with data: " + payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE 请求测试:删除资源
|
||||||
|
* 示例 URL: /api/test/delete/789
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/delete/{id}")
|
||||||
|
public String deleteResource(@PathVariable String id) {
|
||||||
|
return "Resource ID " + id + " deleted.";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.arrokoth.standalone.authorization.domain.request;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class LoginRequest {
|
||||||
|
|
||||||
|
|
||||||
|
private String username;
|
||||||
|
private String password;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.arrokoth.standalone.authorization.domain.response;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class BasicUser {
|
||||||
|
|
||||||
|
private String username;
|
||||||
|
private String avatar;
|
||||||
|
private String introduction;
|
||||||
|
private List<String> roles;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.arrokoth.standalone.authorization.domain.response;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class RestResponse<T> {
|
||||||
|
|
||||||
|
private String code;
|
||||||
|
private String message;
|
||||||
|
private Long timestamp;
|
||||||
|
private T data;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 默认构造函数
|
||||||
|
public RestResponse() {
|
||||||
|
this.timestamp = System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全参构造函数
|
||||||
|
public RestResponse(String code, String message, T data) {
|
||||||
|
this.code = code;
|
||||||
|
this.message = message;
|
||||||
|
this.data = data;
|
||||||
|
this.timestamp = System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为了方便使用,可以添加一些静态方法来创建实例
|
||||||
|
public static <T> RestResponse<T> success(T data) {
|
||||||
|
return new RestResponse<>("200", "success", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> RestResponse<T> error(String code, String message) {
|
||||||
|
return new RestResponse<>(code, message, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.arrokoth.standalone.authorization.domain.response;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class Token {
|
||||||
|
|
||||||
|
private String access_token;
|
||||||
|
private String token_type;
|
||||||
|
private Long expires_in;
|
||||||
|
private String refresh_token;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.arrokoth.standalone.authorization.filter;
|
||||||
|
|
||||||
|
import com.arrokoth.standalone.authorization.store.redis.RedisTokenService;
|
||||||
|
import com.arrokoth.standalone.authorization.util.JwtUtils;
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class JwtRequestFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private final UserDetailsService userDetailsService;
|
||||||
|
|
||||||
|
private final RedisTokenService redisTokenService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
||||||
|
throws ServletException, IOException {
|
||||||
|
|
||||||
|
final String authorizationHeader = request.getHeader("Authorization");
|
||||||
|
|
||||||
|
String jwt = null;
|
||||||
|
String username = null;
|
||||||
|
|
||||||
|
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
|
||||||
|
jwt = authorizationHeader.substring(7);
|
||||||
|
try {
|
||||||
|
username = JwtUtils.extractUsername(jwt);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("Failed to extract username from token", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||||
|
if (redisTokenService.isBlacklisted(jwt)) {
|
||||||
|
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Token is blacklisted");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
|
||||||
|
|
||||||
|
if (JwtUtils.validateToken(jwt, userDetails)) {
|
||||||
|
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
|
||||||
|
userDetails, null, userDetails.getAuthorities());
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chain.doFilter(request, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.arrokoth.standalone.authorization.properties;
|
||||||
|
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 授权服务器相关配置属性
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@ConfigurationProperties(prefix = "arrokoth.authorization.server")
|
||||||
|
public class AuthorizationServerProperties {
|
||||||
|
|
||||||
|
private static final String DEFAULT_LOGIN_PAGE = "/login";
|
||||||
|
private static final String DEFAULT_LOGOUT_SUCCESS_URL = "/login?logout";
|
||||||
|
private static final List<String> DEFAULT_PERMIT_URLS = Arrays.asList(
|
||||||
|
"/login", "/logout", "/connect/logout",
|
||||||
|
"/assets/**", "/static/**", "/webjars/**", "/error", "/oauth2/**"
|
||||||
|
);
|
||||||
|
/**
|
||||||
|
* 授权确认页面路径,默认为 "/oauth2/consent"
|
||||||
|
*/
|
||||||
|
private String consentPage = "/oauth2/consent";
|
||||||
|
/**
|
||||||
|
* OAuth2 授权端点路径,默认为 "/oauth2/authorize"
|
||||||
|
*/
|
||||||
|
private String authorizationEndpoint = "/oauth2/authorize";
|
||||||
|
/**
|
||||||
|
* JWT Issuer 值,默认为 "https://www.arrokoth-info.com"
|
||||||
|
*/
|
||||||
|
private String issuer = "https://www.arrokoth-info.com";
|
||||||
|
/**
|
||||||
|
* 登出成功后的跳转 URL。
|
||||||
|
* 默认值为 {@link #DEFAULT_LOGOUT_SUCCESS_URL},即 "/login?logout"。
|
||||||
|
* 如果在 application.yml 中配置了 app.security.logout-success-url,则使用配置值;
|
||||||
|
* 否则使用该默认值。
|
||||||
|
*/
|
||||||
|
private String logoutSuccessUrl = DEFAULT_LOGOUT_SUCCESS_URL;
|
||||||
|
/**
|
||||||
|
* 需要放行(无需认证即可访问)的 URL 列表。
|
||||||
|
* 默认为空列表,由 {@link #getMergedPermittedUrls()} 方法结合默认值进行合并处理。
|
||||||
|
*/
|
||||||
|
private List<String> permitUrls = new ArrayList<>();
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并默认值与自定义配置,并去重
|
||||||
|
*/
|
||||||
|
public List<String> getMergedPermittedUrls() {
|
||||||
|
return Stream.concat(DEFAULT_PERMIT_URLS.stream(), permitUrls.stream())
|
||||||
|
.distinct()
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package com.arrokoth.standalone.authorization.properties;
|
||||||
|
|
||||||
|
|
||||||
|
import cn.hutool.json.JSONUtil;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 授权服务器相关配置属性
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Data
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "arrokoth.security.web")
|
||||||
|
public class SecurityWebProperties {
|
||||||
|
|
||||||
|
public static final String AXIOS_LOGIN_PROCESSING_URL = "/home/login";
|
||||||
|
|
||||||
|
|
||||||
|
public static final String DEFAULT_LOGIN_PROCESSING_URL = "/web/login";
|
||||||
|
|
||||||
|
private static final String DEFAULT_LOGOUT_SUCCESS_URL = "/login?logout";
|
||||||
|
|
||||||
|
private static final String DEFAULT_LOGOUT_PAGE = "/login";
|
||||||
|
|
||||||
|
|
||||||
|
private static final List<String> DEFAULT_PERMIT_URLS = Arrays.asList(
|
||||||
|
DEFAULT_LOGIN_PROCESSING_URL,
|
||||||
|
"/login", "/logout", "/connect/logout", "/home/login",
|
||||||
|
"/assets/**", "/static/**", "/webjars/**",
|
||||||
|
"/actuator/**",
|
||||||
|
"/error", "/oauth2/**"
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
private static final List<String> SWAGGER_URLS = Arrays.asList(
|
||||||
|
"/swagger-ui/**",
|
||||||
|
"/v3/api-docs/**",
|
||||||
|
"/swagger-resources/**",
|
||||||
|
"/webjars/**",
|
||||||
|
"/doc.html",
|
||||||
|
"/swagger-ui.html"
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登出成功后的跳转 URL。
|
||||||
|
* 默认值为 {@link #DEFAULT_LOGOUT_SUCCESS_URL},即 "/login?logout"。
|
||||||
|
* 如果在 application.yml 中配置了 app.security.logout-success-url,则使用配置值;
|
||||||
|
* 否则使用该默认值。
|
||||||
|
*/
|
||||||
|
private String logoutSuccessUrl = DEFAULT_LOGOUT_SUCCESS_URL;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
private String loginPage = DEFAULT_LOGOUT_PAGE;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理登录请求
|
||||||
|
*/
|
||||||
|
private String loginProcessingUrl = DEFAULT_LOGIN_PROCESSING_URL;
|
||||||
|
/**
|
||||||
|
* 需要放行(无需认证即可访问)的 URL 列表。
|
||||||
|
* 默认为空列表,由 {@link #getMergedPermittedUrls()} 方法结合默认值进行合并处理。
|
||||||
|
*/
|
||||||
|
private List<String> permitUrls = new ArrayList<>();
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并默认值与自定义配置,并去重
|
||||||
|
*/
|
||||||
|
public List<String> getMergedPermittedUrls() {
|
||||||
|
List<String> merged = Stream.of(DEFAULT_PERMIT_URLS, SWAGGER_URLS, permitUrls)
|
||||||
|
.flatMap(List::stream)
|
||||||
|
.distinct()
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
log.info("Merged permitted urls: {}", JSONUtil.toJsonStr(merged));
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.arrokoth.standalone.authorization.service;
|
||||||
|
|
||||||
|
import com.arrokoth.standalone.authorization.domain.request.LoginRequest;
|
||||||
|
import com.arrokoth.standalone.authorization.domain.response.BasicUser;
|
||||||
|
import com.arrokoth.standalone.authorization.domain.response.Token;
|
||||||
|
|
||||||
|
public interface AuthorizationService {
|
||||||
|
|
||||||
|
|
||||||
|
Token login(LoginRequest loginRequest);
|
||||||
|
|
||||||
|
void logout(String jti);
|
||||||
|
|
||||||
|
BasicUser getBasicUser();
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.arrokoth.standalone.authorization.service;
|
||||||
|
|
||||||
|
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
|
||||||
|
|
||||||
|
import java.security.Principal;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public interface OAuth2ConsentService {
|
||||||
|
|
||||||
|
OAuth2ConsentService.ConsentResponse getConsentDetails(Principal principal,
|
||||||
|
String clientId,
|
||||||
|
String scope,
|
||||||
|
String state,
|
||||||
|
String principalName);
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录返回结果,包含待批准、已批准 scopes 和客户端信息
|
||||||
|
*
|
||||||
|
* @param scopesToApprove 需要用户授权的 scopes
|
||||||
|
* @param previouslyApprovedScopes 已经授权过的 scopes
|
||||||
|
* @param registeredClient 客户端注册信息
|
||||||
|
*/
|
||||||
|
record ConsentResponse(
|
||||||
|
Set<String> scopesToApprove,
|
||||||
|
Set<String> previouslyApprovedScopes,
|
||||||
|
RegisteredClient registeredClient
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.arrokoth.standalone.authorization.service.impl;
|
||||||
|
|
||||||
|
import com.arrokoth.standalone.authorization.domain.request.LoginRequest;
|
||||||
|
import com.arrokoth.standalone.authorization.domain.response.BasicUser;
|
||||||
|
import com.arrokoth.standalone.authorization.domain.response.Token;
|
||||||
|
import com.arrokoth.standalone.authorization.service.AuthorizationService;
|
||||||
|
import com.arrokoth.standalone.authorization.util.JwtUtils;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AuthorizationServiceImpl implements AuthorizationService {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Token login(LoginRequest loginRequest) {
|
||||||
|
String admin = JwtUtils.createAccessToken("admin");
|
||||||
|
|
||||||
|
Token token = new Token();
|
||||||
|
token.setAccess_token(admin);
|
||||||
|
token.setExpires_in(JwtUtils.DEFAULT_EXPIRATION);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void logout(String jti) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BasicUser getBasicUser() {
|
||||||
|
BasicUser basicUser = new BasicUser();
|
||||||
|
basicUser.setUsername("admin");
|
||||||
|
basicUser.setAvatar("https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif");
|
||||||
|
basicUser.setIntroduction("I am a super administrator...");
|
||||||
|
basicUser.setRoles(List.of("admin"));
|
||||||
|
return basicUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.arrokoth.standalone.authorization.service.impl;
|
||||||
|
|
||||||
|
import com.arrokoth.standalone.authorization.service.OAuth2ConsentService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.oauth2.core.oidc.OidcScopes;
|
||||||
|
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent;
|
||||||
|
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
|
||||||
|
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
|
||||||
|
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.security.Principal;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth2 授权确认服务实现类
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class OAuth2ConsentServiceImpl implements OAuth2ConsentService {
|
||||||
|
|
||||||
|
private final RegisteredClientRepository registeredClientRepository;
|
||||||
|
private final OAuth2AuthorizationConsentService authorizationConsentService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户授权的详细信息,包括需要审批和已审批的 scopes
|
||||||
|
*
|
||||||
|
* @param clientId 客户端 ID
|
||||||
|
* @param scope 请求的 scope 列表(空格分隔)
|
||||||
|
* @param principalName 用户名
|
||||||
|
* @return 包含待批准和已批准 scopes 的 ConsentResult 对象
|
||||||
|
*/
|
||||||
|
public ConsentResponse getConsentDetails(Principal principal,
|
||||||
|
String clientId,
|
||||||
|
String scope,
|
||||||
|
String state,
|
||||||
|
String principalName) {
|
||||||
|
Set<String> scopesToApprove = new HashSet<>();
|
||||||
|
Set<String> previouslyApprovedScopes = new HashSet<>();
|
||||||
|
|
||||||
|
RegisteredClient registeredClient = Optional.ofNullable(
|
||||||
|
registeredClientRepository.findByClientId(clientId))
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("客户端不存在"));
|
||||||
|
|
||||||
|
OAuth2AuthorizationConsent currentAuthorizationConsent =
|
||||||
|
this.authorizationConsentService.findById(registeredClient.getId(), principalName);
|
||||||
|
|
||||||
|
Set<String> authorizedScopes = Optional.ofNullable(currentAuthorizationConsent)
|
||||||
|
.map(OAuth2AuthorizationConsent::getScopes)
|
||||||
|
.orElse(Set.of());
|
||||||
|
|
||||||
|
Arrays.stream(StringUtils.delimitedListToStringArray(scope, " "))
|
||||||
|
.filter(requestedScope -> !OidcScopes.OPENID.equals(requestedScope))
|
||||||
|
.forEach(requestedScope -> {
|
||||||
|
if (authorizedScopes.contains(requestedScope)) {
|
||||||
|
previouslyApprovedScopes.add(requestedScope);
|
||||||
|
} else {
|
||||||
|
scopesToApprove.add(requestedScope);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return new ConsentResponse(
|
||||||
|
Collections.unmodifiableSet(scopesToApprove),
|
||||||
|
Collections.unmodifiableSet(previouslyApprovedScopes),
|
||||||
|
registeredClient
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.arrokoth.standalone.authorization.store;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.oauth2.server.authorization.InMemoryOAuth2AuthorizationConsentService;
|
||||||
|
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class OAuth2AuthorizationConsentServiceStore {
|
||||||
|
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public OAuth2AuthorizationConsentService authorizationConsentService() {
|
||||||
|
return new InMemoryOAuth2AuthorizationConsentService();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.arrokoth.standalone.authorization.store;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.oauth2.server.authorization.InMemoryOAuth2AuthorizationService;
|
||||||
|
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class OAuth2AuthorizationServiceStore {
|
||||||
|
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public OAuth2AuthorizationService authorizationService() {
|
||||||
|
// 基于db的oauth2认证服务,还有一个基于内存的服务实现InMemoryOAuth2AuthorizationService
|
||||||
|
return new InMemoryOAuth2AuthorizationService();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.arrokoth.standalone.authorization.store;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||||
|
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
|
||||||
|
import org.springframework.security.oauth2.core.oidc.OidcScopes;
|
||||||
|
import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;
|
||||||
|
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
|
||||||
|
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
|
||||||
|
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
|
||||||
|
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class RegisteredClientRepositoryStore {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public RegisteredClientRepository registeredClientRepository() {
|
||||||
|
|
||||||
|
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
|
||||||
|
RegisteredClient oidcClient = RegisteredClient.withId(UUID.randomUUID().toString())
|
||||||
|
|
||||||
|
.clientId("messaging-client")
|
||||||
|
.clientSecret(bCryptPasswordEncoder.encode("secret"))
|
||||||
|
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
|
||||||
|
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||||
|
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
|
||||||
|
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
|
||||||
|
.redirectUri("http://127.0.0.1:8091/login/oauth2/code/messaging-client-oidc")
|
||||||
|
.scope(OidcScopes.OPENID)
|
||||||
|
.scope(OidcScopes.PROFILE)
|
||||||
|
.scope("message.read")
|
||||||
|
.scope("message.write")
|
||||||
|
.clientSettings(ClientSettings.builder().requireAuthorizationConsent(false).build())
|
||||||
|
.tokenSettings(TokenSettings.builder()
|
||||||
|
.accessTokenTimeToLive(Duration.ofHours(1))
|
||||||
|
.refreshTokenTimeToLive(Duration.ofHours(10))
|
||||||
|
.build())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return new InMemoryRegisteredClientRepository(oidcClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.arrokoth.standalone.authorization.store;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.core.userdetails.User;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class UserDetailsServiceStore {
|
||||||
|
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public UserDetailsService users(PasswordEncoder passwordEncoder) {
|
||||||
|
UserDetails user = User.withUsername("admin")
|
||||||
|
.password(passwordEncoder.encode("password"))
|
||||||
|
.roles("admin", "normal")
|
||||||
|
.authorities("app", "web")
|
||||||
|
.build();
|
||||||
|
return new InMemoryUserDetailsManager(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.arrokoth.standalone.authorization.store.redis;
|
||||||
|
|
||||||
|
import com.arrokoth.standalone.authorization.util.JwtUtils;
|
||||||
|
import com.nimbusds.jose.JOSEException;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.text.ParseException;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于 Redis 的 Token 黑名单服务
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class RedisTokenService {
|
||||||
|
|
||||||
|
private static final String BLACKLIST_PREFIX = "blacklist:token:";
|
||||||
|
private final StringRedisTemplate redisTemplate;
|
||||||
|
|
||||||
|
public RedisTokenService(StringRedisTemplate redisTemplate) {
|
||||||
|
this.redisTemplate = redisTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Token 加入黑名单
|
||||||
|
*
|
||||||
|
* @param token JWT Token 字符串
|
||||||
|
*/
|
||||||
|
public void blacklistToken(String token) {
|
||||||
|
try {
|
||||||
|
String jti = JwtUtils.extractJti(token); // 需要你在 JwtUtils 中添加 extractJti 方法
|
||||||
|
long ttl = calculateTtlFromToken(token);
|
||||||
|
String key = BLACKLIST_PREFIX + jti;
|
||||||
|
// 设置黑名单项,并指定过期时间为 Token 剩余有效时间
|
||||||
|
redisTemplate.opsForValue().set(key, "true", ttl, TimeUnit.SECONDS);
|
||||||
|
log.debug("Token [{}] added to blacklist with TTL {} seconds", jti, ttl);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to add token to blacklist", e);
|
||||||
|
throw new RuntimeException("Failed to add token to blacklist", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查 Token 是否在黑名单中
|
||||||
|
*
|
||||||
|
* @param token JWT Token 字符串
|
||||||
|
* @return 是否在黑名单中
|
||||||
|
*/
|
||||||
|
public boolean isBlacklisted(String token) {
|
||||||
|
try {
|
||||||
|
String jti = JwtUtils.extractJti(token);
|
||||||
|
String key = BLACKLIST_PREFIX + jti;
|
||||||
|
return redisTemplate.hasKey(key);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to check token in blacklist", e);
|
||||||
|
return true; // 出错时保守策略:拒绝访问
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Token 剩余有效期(秒)
|
||||||
|
*/
|
||||||
|
private long calculateTtlFromToken(String token) throws ParseException, JOSEException {
|
||||||
|
Date expiration = JwtUtils.extractExpiration(token);
|
||||||
|
long remainingSeconds = (expiration.getTime() - System.currentTimeMillis()) / 1000;
|
||||||
|
return Math.max(1, remainingSeconds); // 至少保留 1 秒
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package com.arrokoth.standalone.authorization.util;
|
||||||
|
|
||||||
|
import com.nimbusds.jose.JOSEException;
|
||||||
|
import com.nimbusds.jose.JWSAlgorithm;
|
||||||
|
import com.nimbusds.jose.JWSHeader;
|
||||||
|
import com.nimbusds.jose.crypto.MACSigner;
|
||||||
|
import com.nimbusds.jose.crypto.MACVerifier;
|
||||||
|
import com.nimbusds.jwt.JWTClaimsSet;
|
||||||
|
import com.nimbusds.jwt.SignedJWT;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.text.ParseException;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public class JwtUtils {
|
||||||
|
|
||||||
|
// ================== 配置常量 ==================
|
||||||
|
private static final String ISSUER = "your-issuer";
|
||||||
|
public static final Long DEFAULT_EXPIRATION = 3600000L; // 默认有效期 1小时 (ms)
|
||||||
|
private static final String SECRET_KEY = generateSecureSecret();
|
||||||
|
|
||||||
|
// ================== 公共方法 ==================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建一个新的访问 Token
|
||||||
|
*
|
||||||
|
* @param username 用户标识(如 username 或 user id)
|
||||||
|
* @return JWT Token 字符串
|
||||||
|
*/
|
||||||
|
public static String createAccessToken(String username) {
|
||||||
|
try {
|
||||||
|
return createAccessToken(username, username, DEFAULT_EXPIRATION);
|
||||||
|
} catch (JOSEException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建一个新的访问 Token,并指定过期时间
|
||||||
|
*
|
||||||
|
* @param subject 用户标识
|
||||||
|
* @param expiration 过期时间(毫秒)
|
||||||
|
* @return JWT Token 字符串
|
||||||
|
*/
|
||||||
|
public static String createAccessToken(String subject, String username, Long expiration) throws JOSEException {
|
||||||
|
JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
|
||||||
|
.subject(subject)
|
||||||
|
.issuer(ISSUER)
|
||||||
|
.issueTime(new Date())
|
||||||
|
.expirationTime(new Date(System.currentTimeMillis() + expiration))
|
||||||
|
.jwtID(UUID.randomUUID().toString())
|
||||||
|
.claim("username", username) // 自定义字段存 username
|
||||||
|
.build();
|
||||||
|
|
||||||
|
JWSHeader header = new JWSHeader(JWSAlgorithm.HS256);
|
||||||
|
SignedJWT signedJWT = new SignedJWT(header, claimsSet);
|
||||||
|
|
||||||
|
MACSigner signer = new MACSigner(SECRET_KEY.getBytes());
|
||||||
|
signedJWT.sign(signer);
|
||||||
|
|
||||||
|
return signedJWT.serialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析并验证 Token
|
||||||
|
*
|
||||||
|
* @param token 要解析的 JWT 字符串
|
||||||
|
* @return JWT 的 Claims
|
||||||
|
*/
|
||||||
|
public static JWTClaimsSet parseToken(String token) throws ParseException, JOSEException {
|
||||||
|
SignedJWT signedJWT = SignedJWT.parse(token);
|
||||||
|
|
||||||
|
MACVerifier verifier = new MACVerifier(SECRET_KEY.getBytes());
|
||||||
|
if (!signedJWT.verify(verifier)) {
|
||||||
|
throw new JOSEException("Invalid signature");
|
||||||
|
}
|
||||||
|
|
||||||
|
return signedJWT.getJWTClaimsSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取用户名
|
||||||
|
*
|
||||||
|
* @param token JWT Token 字符串
|
||||||
|
* @return 用户名
|
||||||
|
*/
|
||||||
|
public static String extractUsername(String token) throws ParseException, JOSEException {
|
||||||
|
return parseToken(token).getSubject();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Token 的过期时间
|
||||||
|
*
|
||||||
|
* @param token JWT Token 字符串
|
||||||
|
* @return 过期时间
|
||||||
|
*/
|
||||||
|
public static Date extractExpiration(String token) throws ParseException, JOSEException {
|
||||||
|
return parseToken(token).getExpirationTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Token 的 jti (JWT ID)
|
||||||
|
*
|
||||||
|
* @param token JWT Token 字符串
|
||||||
|
* @return jti 值
|
||||||
|
*/
|
||||||
|
public static String extractJti(String token) throws ParseException, JOSEException {
|
||||||
|
return parseToken(token).getJWTID();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断 Token 是否已过期
|
||||||
|
*
|
||||||
|
* @param token JWT Token 字符串
|
||||||
|
* @return 是否有效
|
||||||
|
*/
|
||||||
|
public static boolean isTokenExpired(String token) {
|
||||||
|
try {
|
||||||
|
return extractExpiration(token).before(new Date());
|
||||||
|
} catch (Exception e) {
|
||||||
|
return true; // 出错也视为过期
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 Token 是否属于当前用户且未过期
|
||||||
|
*
|
||||||
|
* @param token JWT Token 字符串
|
||||||
|
* @param userDetails 用户信息
|
||||||
|
* @return 是否有效
|
||||||
|
*/
|
||||||
|
public static boolean validateToken(String token, UserDetails userDetails) {
|
||||||
|
try {
|
||||||
|
String username = extractUsername(token);
|
||||||
|
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================== 私有工具方法 ==================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成安全的密钥(Base64 编码的 256 位密钥)
|
||||||
|
*/
|
||||||
|
private static String generateSecureSecret() {
|
||||||
|
byte[] key = new byte[32]; // 256 bits
|
||||||
|
new SecureRandom().nextBytes(key);
|
||||||
|
return Base64.getEncoder().encodeToString(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 提取 Token 的辅助方法
|
||||||
|
public static String extractTokenFromHeader(HttpServletRequest request) {
|
||||||
|
String header = request.getHeader("Authorization");
|
||||||
|
if (header != null && header.startsWith("Bearer ")) {
|
||||||
|
return header.substring(7);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================== 测试入口 ==================
|
||||||
|
public static void main(String[] args) {
|
||||||
|
try {
|
||||||
|
String token = createAccessToken("user@example.com");
|
||||||
|
System.out.println("Generated Token: " + token);
|
||||||
|
|
||||||
|
JWTClaimsSet claims = parseToken(token);
|
||||||
|
System.out.println("Subject: " + claims.getSubject());
|
||||||
|
System.out.println("Issuer: " + claims.getIssuer());
|
||||||
|
System.out.println("Expiration Time: " + claims.getExpirationTime());
|
||||||
|
|
||||||
|
System.out.println("Is Valid? " + validateToken(token, null));
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/main/resources/application.yml
Normal file
36
src/main/resources/application.yml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
server:
|
||||||
|
port: 8080
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: authorization-server-standalone
|
||||||
|
data:
|
||||||
|
redis:
|
||||||
|
host: localhost
|
||||||
|
port: 6379
|
||||||
|
password: yyds@8848
|
||||||
|
lettuce:
|
||||||
|
pool:
|
||||||
|
max-active: 8 # 最大连接数
|
||||||
|
max-idle: 4 # 最大空闲连接
|
||||||
|
min-idle: 1 # 最小空闲连接
|
||||||
|
max-wait: 2000ms # 获取连接最大等待时间
|
||||||
|
|
||||||
|
|
||||||
|
arrokoth:
|
||||||
|
authorization:
|
||||||
|
server:
|
||||||
|
consent-page: /oauth2/consent
|
||||||
|
authorization-endpoint: /oauth2/authorize
|
||||||
|
issuer: https://www.arrokoth-info.com
|
||||||
|
security:
|
||||||
|
web:
|
||||||
|
login-page: /login
|
||||||
|
logout-success-url: /login?logout
|
||||||
|
permit-urls:
|
||||||
|
- /home/login
|
||||||
|
|
||||||
|
|
||||||
87
src/main/resources/templates/consent.html
Normal file
87
src/main/resources/templates/consent.html
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>授权 |统一身份认证服务平台</title>
|
||||||
|
<!-- Bootstrap CSS -->
|
||||||
|
<link rel="stylesheet" th:href="@{/assets/css/bootstrap.min.css}"
|
||||||
|
integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
|
||||||
|
<!-- Custom Styles -->
|
||||||
|
<link rel="stylesheet" th:href="@{/assets/css/consent.css}" crossorigin="anonymous">
|
||||||
|
<script>
|
||||||
|
function cancelConsent() {
|
||||||
|
document.consent_form.reset();
|
||||||
|
document.consent_form.submit();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Header Section -->
|
||||||
|
<div class="header">
|
||||||
|
<span>OAuth 授权请求</span>
|
||||||
|
<div class="header__links">
|
||||||
|
<a href="#" class="header__link">seven</a>
|
||||||
|
<span>|</span>
|
||||||
|
<a href="#" class="header__link">换个帐号?</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="main-container">
|
||||||
|
<div class="app-info">
|
||||||
|
<!-- <img src="/assets/images/app-icon.png" alt="App Icon" class="app-info__icon">-->
|
||||||
|
<div>
|
||||||
|
<span class="app-info__name" th:text="${clientId}"></span>
|
||||||
|
<p class="app-info__description">此第三方应用请求获得以下权限:</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form name="consent_form" method="post" th:action="${requestURI}">
|
||||||
|
<input type="hidden" name="client_id" th:value="${clientId}">
|
||||||
|
<input type="hidden" name="state" th:value="${state}">
|
||||||
|
<input th:if="${userCode}" type="hidden" name="user_code" th:value="${userCode}">
|
||||||
|
<!-- Requested Scopes -->
|
||||||
|
<ul class="permissions-list">
|
||||||
|
<li th:each="scope : ${scopes}" class="permissions-item">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" class="permissions-item__checkbox" name="scope" th:value="${scope.scope}"
|
||||||
|
th:id="${scope.scope}">
|
||||||
|
<span th:text="${scope.description}"></span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<!-- Previously Approved Scopes -->
|
||||||
|
<p th:if="${not #lists.isEmpty(previouslyApprovedScopes)}">你已授予以下权限:</p>
|
||||||
|
<ul class="permissions-list">
|
||||||
|
<li th:each="scope : ${previouslyApprovedScopes}" class="permissions-item">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="scope" disabled checked th:id="${scope.scope}">
|
||||||
|
<span th:text="${scope.description}"></span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="buttons">
|
||||||
|
<button class="button button--primary" type="submit" id="submit-consent">同意授权</button>
|
||||||
|
<button class="button button--secondary" type="button" id="cancel-consent" onclick="cancelConsent();">拒绝
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p style="margin-top: 2rem;">
|
||||||
|
你可以随时在 <a href="#">帐户设置 > 第三方应用</a> 中取消你的授权。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer Section -->
|
||||||
|
<div class="footer">
|
||||||
|
<div class="footer__links">
|
||||||
|
<div class="footer__link"><a href="#">隐私政策</a></div>
|
||||||
|
<div class="footer__link"><a href="#">服务条款</a></div>
|
||||||
|
<div class="footer__link"><a href="#">联系我们</a></div>
|
||||||
|
</div>
|
||||||
|
<p>© 北京阿罗科斯信息技术有限责任公司 版权所有 | 联系我们 | 帮助中心 |</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
410
src/main/resources/templates/login.html
Normal file
410
src/main/resources/templates/login.html
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>登录 | 统一身份认证服务平台</title>
|
||||||
|
<!-- 引入 Bootstrap 样式 -->
|
||||||
|
<link rel="stylesheet" th:href="@{/assets/css/bootstrap.min.css}"
|
||||||
|
integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
|
||||||
|
|
||||||
|
<!-- 自定义样式 -->
|
||||||
|
<style>
|
||||||
|
/* 全局样式 */
|
||||||
|
body {
|
||||||
|
background: linear-gradient(135deg, #f5f7fa, #c3cfe2);
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 820px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-panel {
|
||||||
|
width: 50%;
|
||||||
|
padding: 20px;
|
||||||
|
background: linear-gradient(135deg, #4a5568, #333);
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-panel {
|
||||||
|
width: 50%;
|
||||||
|
padding: 30px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.login-container {
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-panel, .right-panel {
|
||||||
|
width: 100%;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab 样式 */
|
||||||
|
.login-form {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: #333;
|
||||||
|
font-size: 16px;
|
||||||
|
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form a.active {
|
||||||
|
border-bottom: 2px solid #e74c3c;
|
||||||
|
color: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab 内容样式 */
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
min-height: 260px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表单样式 */
|
||||||
|
form {
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
width: 70px; /* 固定宽度,确保所有标签对齐 */
|
||||||
|
text-align: right;
|
||||||
|
margin-right: 10px; /* 距离输入框留出空隙 */
|
||||||
|
color: #333;
|
||||||
|
font-weight: normal;
|
||||||
|
white-space: nowrap; /* 防止换行 */
|
||||||
|
}
|
||||||
|
.form-group input,
|
||||||
|
.form-group select,
|
||||||
|
.form-group .captcha-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 5px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input{
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pass-captcha-input{
|
||||||
|
width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#captcha-image{
|
||||||
|
margin-left: 2px; /* 验证码图片与输入框之间的间距 */
|
||||||
|
/*width: 70px;*/
|
||||||
|
/*height: 50px;*/
|
||||||
|
vertical-align: middle;
|
||||||
|
cursor:pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
background: linear-gradient(90deg, #e74c3c, #c0392b);
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: white;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.captcha-btn {
|
||||||
|
display: inline-block;
|
||||||
|
color: #333;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.submit-btn:disabled, .captcha-btn:disabled {
|
||||||
|
background-color: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.submit-btn:hover {
|
||||||
|
background: linear-gradient(90deg, #c0392b, #e74c3c);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 社交登录样式 */
|
||||||
|
.social-login {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-login a {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #333;
|
||||||
|
margin: 0 10px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-login a:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
color: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 页脚样式 */
|
||||||
|
footer {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
padding: 10px 0;
|
||||||
|
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
footer p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #777;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- CSRF Token -->
|
||||||
|
<meta name="_csrf" content="${_csrf.token}"/>
|
||||||
|
<meta name="_csrf_header" content="${_csrf.headerName}"/>
|
||||||
|
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="left-panel">
|
||||||
|
<h3>企业级 统一身份认证服务平台</h3>
|
||||||
|
<p>
|
||||||
|
</p>
|
||||||
|
<p>为企业量身定制的安全保障</p>
|
||||||
|
<p>
|
||||||
|
旨在提供一种简单而强大而灵活的认证解决方案,以保护企业应用的安全性和隐私性。它允许用户通过一组凭据(如用户名和密码、手机号验证码或第三方社交账号)登录到多个应用或服务,而无需对每个应用单独进行注册和身份验证</p>
|
||||||
|
<hr>
|
||||||
|
<p>构建于信任之上,确保数据安全与隐私</p>
|
||||||
|
</div>
|
||||||
|
<div class="right-panel">
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h3>登录 | 统一身份认证服务平台</h3>
|
||||||
|
<p>高效安全,一站式登录体验</p>
|
||||||
|
</div>
|
||||||
|
<!-- Tab 导航 -->
|
||||||
|
<div class="login-form">
|
||||||
|
<a href="#" class="tab-link active" data-tab="password-tab">密码登录</a>
|
||||||
|
<a href="#" class="tab-link" data-tab="wechat-tab">微信登录</a>
|
||||||
|
<a href="#" class="tab-link" data-tab="password-less-tab">免密登录</a>
|
||||||
|
</div>
|
||||||
|
<div class="tab-content active" id="password-tab">
|
||||||
|
<form method="post" th:action="@{/web/login}">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username-input">用户名</label>
|
||||||
|
<input id="username-input" name="username" placeholder="请输入用户名" type="text" value="admin">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password-input">密码</label>
|
||||||
|
<input id="password-input" name="password" placeholder="请输入密码" type="password"
|
||||||
|
value="password">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="display: none">
|
||||||
|
<label for="pass-captcha-input">验证码</label>
|
||||||
|
<div>
|
||||||
|
<input id="pass-captcha-input" placeholder="请输入验证码" type="text"/>
|
||||||
|
<!-- 新增:验证码图片展示 -->
|
||||||
|
<img id="captcha-image" alt="验证码"
|
||||||
|
src="" onclick="refreshCaptcha()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-danger" role="alert" th:if="${param.error}">用户名或密码输入错误,请重新输入。</div>
|
||||||
|
<button class="submit-btn" id="password-tab-submit">登录</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<!-- Tab 内容 -->
|
||||||
|
<div class="tab-content" id="wechat-tab">
|
||||||
|
<p>请使用微信扫描二维码登录:</p>
|
||||||
|
<div style="height: 200px;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-content" id="password-less-tab">
|
||||||
|
<form method="post" th:action="@{/oauth2/email/login}" id="loginForm">
|
||||||
|
<!-- <input type="hidden" name="_csrf" th:value="${_csrf.token}"/>-->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="phone-input">手机号/邮箱</label>
|
||||||
|
<input type="text" id="phone-input" name="email" value="546732225@qq.com"
|
||||||
|
placeholder="请输入手机号/邮箱" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="captcha-input">验证码</label>
|
||||||
|
<input type="text" id="captcha-input" name="code" placeholder="请输入验证码" required>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="captcha-btn" id="sendCaptchaBtn">发送验证码</button>
|
||||||
|
<button type="submit" class="submit-btn" id="submitBtn" disabled>登录</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>注册登录即表示同意 <a href="#">《认证服务条款》</a> 和 <a href="#">《隐私协议》</a></p>
|
||||||
|
</div>
|
||||||
|
<div class="social-login">
|
||||||
|
<a href="#"><i class="fab fa-weixin"></i></a>
|
||||||
|
<a href="#"><i class="fab fa-qq"></i></a>
|
||||||
|
<a href="#"><i class="fab fa-weibo"></i></a>
|
||||||
|
<a href="#"><i class="fab fa-baidu"></i></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>© 北京阿罗科斯信息技术有限责任公司 版权所有 | 联系我们 | 帮助中心 |</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- JavaScript 实现 Tab 切换 -->
|
||||||
|
<script th:inline="javascript">
|
||||||
|
const CONTEXT_PATH = /*[[ @{/} ]]*/ ''; // 动态获取 context-path
|
||||||
|
|
||||||
|
// 页面加载时加载验证码
|
||||||
|
window.onload = function () {
|
||||||
|
// refreshCaptcha();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.querySelectorAll('.tab-link').forEach(link => {
|
||||||
|
link.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// 移除所有 Tab 的 active 类
|
||||||
|
document.querySelectorAll('.tab-link').forEach(tab => tab.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
|
||||||
|
|
||||||
|
// 添加当前 Tab 的 active 类
|
||||||
|
this.classList.add('active');
|
||||||
|
const tabId = this.getAttribute('data-tab');
|
||||||
|
document.getElementById(tabId).classList.add('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const sendCaptchaBtn = document.getElementById('sendCaptchaBtn');
|
||||||
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
|
const emailInput = document.getElementById('phone-input');
|
||||||
|
|
||||||
|
let countdown = 60; // 倒计时时间
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
// 获取 CSRF Token
|
||||||
|
const csrfToken = document.querySelector('meta[name="_csrf"]').getAttribute('content');
|
||||||
|
const csrfHeader = document.querySelector('meta[name="_csrf_header"]').getAttribute('content');
|
||||||
|
|
||||||
|
// 点击发送验证码按钮
|
||||||
|
sendCaptchaBtn.addEventListener('click', function () {
|
||||||
|
const email = emailInput.value.trim();
|
||||||
|
if (!email) {
|
||||||
|
alert('请输入有效的手机号/邮箱');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发起 POST 请求
|
||||||
|
fetch(`${CONTEXT_PATH}oauth2/email/generate?email=${encodeURIComponent(email)}`)
|
||||||
|
.then(response => {
|
||||||
|
if (response.ok) {
|
||||||
|
return response.json();
|
||||||
|
} else {
|
||||||
|
throw new Error('验证码发送失败');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
// 启用提交按钮
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
// 禁用发送验证码按钮并开始倒计时
|
||||||
|
sendCaptchaBtn.disabled = true;
|
||||||
|
sendCaptchaBtn.textContent = `重新发送(${countdown})`;
|
||||||
|
|
||||||
|
timer = setInterval(() => {
|
||||||
|
countdown--;
|
||||||
|
sendCaptchaBtn.textContent = `重新发送(${countdown})`;
|
||||||
|
if (countdown <= 0) {
|
||||||
|
clearInterval(timer);
|
||||||
|
sendCaptchaBtn.disabled = false;
|
||||||
|
sendCaptchaBtn.textContent = '发送验证码';
|
||||||
|
countdown = 60; // 重置倒计时
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('验证码发送失败,请稍后再试');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function refreshCaptcha() {
|
||||||
|
fetch(`${CONTEXT_PATH}oauth/web/captcha`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success && data.data && data.data.image) {
|
||||||
|
document.getElementById('captcha-image').src = data.data.image;
|
||||||
|
} else {
|
||||||
|
console.error('Failed to retrieve captcha:', data.message || 'Unknown error');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Error fetching captcha:', error));
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.arrokoth.standalone.authorization;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
class StandaloneServerApplicationTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void contextLoads() {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user