OIDC should be good to go

This commit is contained in:
Donald Zou 2025-07-03 19:20:01 +08:00
parent bf74150f62
commit aa66a5ffb2
5 changed files with 109 additions and 53 deletions

View File

@ -21,7 +21,7 @@ def ResponseObject(status=True, message=None, data=None, status_code = 200) -> F
def login_required(f): def login_required(f):
@wraps(f) @wraps(f)
def func(*args, **kwargs): def func(*args, **kwargs):
if session.get("Email") is None or session.get("totpVerified") is None or not session.get("totpVerified") or session.get("role") != "client": if session.get("Email") is None or session.get("TotpVerified") is None or not session.get("TotpVerified") or session.get("Role") != "client":
return ResponseObject(False, "Unauthorized access.", data=None, status_code=401) return ResponseObject(False, "Unauthorized access.", data=None, status_code=401)
return f(*args, **kwargs) return f(*args, **kwargs)
return func return func
@ -60,8 +60,8 @@ def createClientBlueprint(wireguardConfigurations: dict[WireguardConfiguration],
return ResponseObject(status, oidcData) return ResponseObject(status, oidcData)
session['Email'] = oidcData.get('email') session['Email'] = oidcData.get('email')
session['role'] = 'client' session['Role'] = 'client'
session['totpVerified'] = True session['TotpVerified'] = True
return ResponseObject() return ResponseObject()
@ -71,15 +71,15 @@ def createClientBlueprint(wireguardConfigurations: dict[WireguardConfiguration],
status, msg = DashboardClients.SignIn(**data) status, msg = DashboardClients.SignIn(**data)
if status: if status:
session['Email'] = data.get('Email') session['Email'] = data.get('Email')
session['role'] = 'client' session['Role'] = 'client'
session['totpVerified'] = False session['TotpVerified'] = False
return ResponseObject(status, msg) return ResponseObject(status, msg)
@client.get(f'{prefix}/api/signout') @client.get(f'{prefix}/api/signout')
def ClientAPI_SignOut(): def ClientAPI_SignOut():
session['Email'] = None if session.get("SignInMethod") == "OIDC":
session['role'] = None DashboardClients.SignOut_OIDC()
session['totpVerified'] = None session.clear()
return ResponseObject(True) return ResponseObject(True)
@client.get(f'{prefix}/api/signin/totp') @client.get(f'{prefix}/api/signin/totp')
@ -102,7 +102,7 @@ def createClientBlueprint(wireguardConfigurations: dict[WireguardConfiguration],
if status: if status:
if session.get('Email') is None: if session.get('Email') is None:
return ResponseObject(False, "Sign in status is invalid", status_code=401) return ResponseObject(False, "Sign in status is invalid", status_code=401)
session['totpVerified'] = True session['TotpVerified'] = True
return ResponseObject(True, data={ return ResponseObject(True, data={
"Email": session.get('Email'), "Email": session.get('Email'),
"Profile": DashboardClients.GetClientProfile(session.get("ClientID")) "Profile": DashboardClients.GetClientProfile(session.get("ClientID"))

View File

@ -4,6 +4,7 @@ import uuid
import bcrypt import bcrypt
import pyotp import pyotp
import sqlalchemy as db import sqlalchemy as db
import requests
from .ConnectionString import ConnectionString from .ConnectionString import ConnectionString
from .DashboardClientsPeerAssignment import DashboardClientsPeerAssignment from .DashboardClientsPeerAssignment import DashboardClientsPeerAssignment
@ -133,17 +134,32 @@ class DashboardClients:
return True, newClientUUID return True, newClientUUID
return False, "User already signed up" return False, "User already signed up"
def SignOut_OIDC(self):
sessionPayload = session.get('OIDCPayload')
status, oidc_config = self.OIDC.GetProviderConfiguration(session.get('SignInPayload').get("Provider"))
signOut = requests.get(
oidc_config.get("end_session_endpoint"),
params={
'id_token_hint': session.get('SignInPayload').get("Payload").get('sid')
}
)
return True
def SignIn_OIDC(self, **kwargs): def SignIn_OIDC(self, **kwargs):
status, data = self.OIDC.VerifyToken(**kwargs) status, data = self.OIDC.VerifyToken(**kwargs)
if not status: if not status:
return False, "Sign in failed" return False, "Sign in failed. Reason: " + data
existingClient = self.SignIn_OIDC_UserExistence(data) existingClient = self.SignIn_OIDC_UserExistence(data)
if not existingClient: if not existingClient:
status, newClientUUID = self.SignUp_OIDC(data) status, newClientUUID = self.SignUp_OIDC(data)
session['ClientID'] = newClientUUID session['ClientID'] = newClientUUID
else: else:
session['ClientID'] = existingClient.get("ClientID") session['ClientID'] = existingClient.get("ClientID")
session['SignInMethod'] = 'OIDC'
session['SignInPayload'] = {
"Provider": kwargs.get('provider'),
"Payload": data
}
return True, data return True, data
def SignIn(self, Email, Password) -> tuple[bool, str]: def SignIn(self, Email, Password) -> tuple[bool, str]:
@ -153,6 +169,7 @@ class DashboardClients:
if existingClient: if existingClient:
checkPwd = self.SignIn_ValidatePassword(Email, Password) checkPwd = self.SignIn_ValidatePassword(Email, Password)
if checkPwd: if checkPwd:
session['SignInMethod'] = 'local'
session['Email'] = Email session['Email'] = Email
session['ClientID'] = existingClient.get("ClientID") session['ClientID'] = existingClient.get("ClientID")
return True, self.DashboardClientsTOTP.GenerateToken(existingClient.get("ClientID")) return True, self.DashboardClientsTOTP.GenerateToken(existingClient.get("ClientID"))

View File

@ -10,6 +10,7 @@ class DashboardOIDC:
ConfigurationFilePath = os.path.join(ConfigurationPath, 'wg-dashboard-oidc-providers.json') ConfigurationFilePath = os.path.join(ConfigurationPath, 'wg-dashboard-oidc-providers.json')
def __init__(self): def __init__(self):
self.providers: dict[str, dict] = {} self.providers: dict[str, dict] = {}
self.provider_secret: dict[str, str] = {}
self.__default = { self.__default = {
'Provider': { 'Provider': {
'client_id': '', 'client_id': '',
@ -26,52 +27,36 @@ class DashboardOIDC:
self.ReadFile() self.ReadFile()
def GetProviders(self): def GetProviders(self):
providers = {} return self.providers
for k in self.providers.keys():
if all([self.providers[k]['client_id'], self.providers[k]['client_secret'], self.providers[k]['issuer']]):
try:
print("Requesting " + f"{self.providers[k]['issuer'].strip('/')}/.well-known/openid-configuration")
oidc_config = requests.get(
f"{self.providers[k]['issuer'].strip('/')}/.well-known/openid-configuration",
verify=certifi.where()
).json()
providers[k] = {
'client_id': self.providers[k]['client_id'],
'issuer': self.providers[k]['issuer'].strip('/')
}
except Exception as e:
current_app.logger.error("Failed to request OIDC config for this provider: " + self.providers[k]['issuer'].strip('/'), exc_info=e)
return providers
def VerifyToken(self, provider, code, redirect_uri): def VerifyToken(self, provider, code, redirect_uri):
try: try:
if not all([provider, code, redirect_uri]): if not all([provider, code, redirect_uri]):
return False, "" return False, "Please provide all parameters"
if provider not in self.providers.keys(): if provider not in self.providers.keys():
return False, "Provider does not exist" return False, "Provider does not exist"
provider = self.providers.get(provider) secrete = self.provider_secret.get(provider)
oidc_config = requests.get( oidc_config_status, oidc_config = self.GetProviderConfiguration(provider)
f"{provider.get('issuer').strip('/')}/.well-known/openid-configuration", provider_info = self.providers.get(provider)
verify=certifi.where()
).json()
data = { data = {
"grant_type": "authorization_code", "grant_type": "authorization_code",
"code": code, "code": code,
"redirect_uri": redirect_uri, "redirect_uri": redirect_uri,
"client_id": provider.get('client_id'), "client_id": provider_info.get('client_id'),
"client_secret": provider.get('client_secret') "client_secret": secrete
} }
try: try:
tokens = requests.post(oidc_config.get('token_endpoint'), data=data).json() tokens = requests.post(oidc_config.get('token_endpoint'), data=data).json()
if not all([tokens.get('access_token'), tokens.get('id_token')]): if not all([tokens.get('access_token'), tokens.get('id_token')]):
print(oidc_config.get('token_endpoint'), data)
return False, tokens.get('error_description', None) return False, tokens.get('error_description', None)
except Exception as e: except Exception as e:
print(str(e))
return False, str(e) return False, str(e)
access_token = tokens.get('access_token') access_token = tokens.get('access_token')
@ -85,30 +70,57 @@ class DashboardOIDC:
key = next(k for k in jwks["keys"] if k["kid"] == kid) key = next(k for k in jwks["keys"] if k["kid"] == kid)
print(key)
payload = jwt.decode( payload = jwt.decode(
id_token, id_token,
key, key,
algorithms=[key["alg"]], algorithms=[key["alg"]],
audience=provider.get('client_id'), audience=provider_info.get('client_id'),
issuer=issuer, issuer=issuer,
access_token=access_token access_token=access_token
) )
print(payload)
return True, payload return True, payload
except Exception as e: except Exception as e:
current_app.logger.error('Read OIDC file failed. Reason: ' + str(e), provider, code, redirect_uri) current_app.logger.error('Read OIDC file failed. Reason: ' + str(e), provider, code, redirect_uri)
return False, str(e) return False, str(e)
def GetProviderConfiguration(self, provider_name):
if not all([provider_name]):
return False, None
provider = self.providers.get(provider_name)
try:
oidc_config = requests.get(
f"{provider.get('issuer').strip('/')}/.well-known/openid-configuration",
verify=certifi.where()
).json()
except Exception as e:
current_app.logger.error("Failed to get OpenID Configuration of " + provider.get('issuer'), exc_info=e)
return False, None
return True, oidc_config
def ReadFile(self): def ReadFile(self):
decoder = json.JSONDecoder() decoder = json.JSONDecoder()
try: try:
self.providers = decoder.decode( providers = decoder.decode(
open(DashboardOIDC.ConfigurationFilePath, 'r').read() open(DashboardOIDC.ConfigurationFilePath, 'r').read()
) )
print(self.providers) for k in providers.keys():
if all([providers[k]['client_id'], providers[k]['client_secret'], providers[k]['issuer']]):
try:
print("Requesting " + f"{providers[k]['issuer'].strip('/')}/.well-known/openid-configuration")
oidc_config = requests.get(
f"{providers[k]['issuer'].strip('/')}/.well-known/openid-configuration",
timeout=3,
verify=certifi.where()
).json()
self.providers[k] = {
'client_id': providers[k]['client_id'],
'issuer': providers[k]['issuer'].strip('/'),
'openid_configuration': oidc_config
}
self.provider_secret[k] = providers[k]['client_secret']
except Exception as e:
current_app.logger.error("Failed to request OIDC config for this provider: " + providers[k]['issuer'].strip('/'), exc_info=e)
except Exception as e: except Exception as e:
current_app.logger.error('Read OIDC file failed. Reason: ' + str(e)) current_app.logger.error('Read OIDC file failed. Reason: ' + str(e))
return False return False

View File

@ -21,13 +21,22 @@ const initApp = () => {
app.mount("#app") app.mount("#app")
} }
function removeSearchString() {
let url = new URL(window.location.href);
url.search = ''; // Remove all query parameters
history.replaceState({}, document.title, url.toString());
}
if (state && code){ if (state && code){
axiosPost("/api/signin/oidc", { axiosPost("/api/signin/oidc", {
provider: state, provider: state,
code: code, code: code,
redirect_uri: window.location.protocol + '//' + window.location.host + window.location.pathname redirect_uri: window.location.protocol + '//' + window.location.host + window.location.pathname
}).then(data => { }).then(data => {
window.location.search = '' let url = new URL(window.location.href);
url.search = '';
history.replaceState({}, document.title, url.toString());
initApp() initApp()
if (!data.status){ if (!data.status){
const store = clientStore() const store = clientStore()

View File

@ -1,9 +1,10 @@
<script setup async> <script setup async>
import {computed, onMounted, ref} from "vue"; import {computed, onMounted, ref} from "vue";
import {axiosGet} from "@/utilities/request.js"; import {axiosGet, requestURl} from "@/utilities/request.js";
import {clientStore} from "@/stores/clientStore.js"; import {clientStore} from "@/stores/clientStore.js";
import Configuration from "@/components/Configuration/configuration.vue"; import Configuration from "@/components/Configuration/configuration.vue";
import {onBeforeRouteLeave} from "vue-router"; import {onBeforeRouteLeave, useRouter} from "vue-router";
import axios from "axios";
const store = clientStore() const store = clientStore()
const loading = ref(true) const loading = ref(true)
@ -23,7 +24,20 @@ onMounted(async () => {
onBeforeRouteLeave(() => { onBeforeRouteLeave(() => {
clearInterval(refreshInterval.value) clearInterval(refreshInterval.value)
}) });
const router = useRouter()
const signingOut = ref(false)
const signOut = async () => {
clearInterval(refreshInterval.value)
signingOut.value = true;
await axios.get(requestURl('/api/signout')).then(() => {
router.push('/signin')
}).catch(() => {
router.push('/signin')
});
store.newNotification("Sign out successful", "success")
}
</script> </script>
<template> <template>
@ -37,10 +51,14 @@ onBeforeRouteLeave(() => {
<i class="bi bi-gear-fill me-sm-2"></i> <i class="bi bi-gear-fill me-sm-2"></i>
<span>Settings</span> <span>Settings</span>
</RouterLink> </RouterLink>
<RouterLink to="/signout" class="btn btn-outline-danger rounded-3 btn-sm" aria-current="page"> <a role="button" @click="signOut()" class="btn btn-outline-danger rounded-3 btn-sm"
:class="{disabled: signingOut}"
aria-current="page">
<i class="bi bi-box-arrow-left me-sm-2"></i> <i class="bi bi-box-arrow-left me-sm-2"></i>
<span>Sign Out</span> <span>
</RouterLink> {{ signingOut ? 'Signing out...':'Sign Out'}}
</span>
</a>
</div> </div>
</div> </div>