mirror of
https://github.com/umami-software/umami.git
synced 2025-12-06 01:18:00 +01:00
Merge 346ebaee4e into 777515f754
This commit is contained in:
commit
a24e871e46
11 changed files with 205 additions and 4 deletions
|
|
@ -268,4 +268,12 @@ model Revenue {
|
|||
@@index([websiteId, createdAt])
|
||||
@@index([websiteId, sessionId, createdAt])
|
||||
@@map("revenue")
|
||||
}
|
||||
}
|
||||
|
||||
model OidcState {
|
||||
state String @id() @unique() @db.VarChar(255)
|
||||
codeVerifier String @db.VarChar(255)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0)
|
||||
|
||||
@@map("oidc_state")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -268,4 +268,13 @@ model Revenue {
|
|||
@@index([websiteId, createdAt])
|
||||
@@index([websiteId, sessionId, createdAt])
|
||||
@@map("revenue")
|
||||
}
|
||||
}
|
||||
|
||||
model OidcState {
|
||||
state String @id() @unique() @db.VarChar(255)
|
||||
codeVerifier String @db.VarChar(255)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
@@index([createdAt])
|
||||
@@map("oidc_state")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@
|
|||
"next": "15.4.7",
|
||||
"node-fetch": "^3.2.8",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"openid-client": "^6.6.4",
|
||||
"papaparse": "^5.5.3",
|
||||
"prisma": "6.7.0",
|
||||
"pure-rand": "^6.1.0",
|
||||
|
|
|
|||
21
pnpm-lock.yaml
generated
21
pnpm-lock.yaml
generated
|
|
@ -131,6 +131,9 @@ importers:
|
|||
npm-run-all:
|
||||
specifier: ^4.1.5
|
||||
version: 4.1.5
|
||||
openid-client:
|
||||
specifier: ^6.6.4
|
||||
version: 6.6.4
|
||||
papaparse:
|
||||
specifier: ^5.5.3
|
||||
version: 5.5.3
|
||||
|
|
@ -4477,6 +4480,9 @@ packages:
|
|||
node-notifier:
|
||||
optional: true
|
||||
|
||||
jose@6.0.12:
|
||||
resolution: {integrity: sha512-T8xypXs8CpmiIi78k0E+Lk7T2zlK4zDyg+o1CZ4AkOHgDg98ogdP2BeZ61lTFKFyoEwJ9RgAgN+SdM3iPgNonQ==}
|
||||
|
||||
joycon@3.1.1:
|
||||
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -5004,6 +5010,9 @@ packages:
|
|||
nth-check@2.1.1:
|
||||
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
|
||||
|
||||
oauth4webapi@3.7.0:
|
||||
resolution: {integrity: sha512-Q52wTPUWPsVLVVmTViXPQFMW2h2xv2jnDGxypjpelCFKaOjLsm7AxYuOk1oQgFm95VNDbuggasu9htXrz6XwKw==}
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -5047,6 +5056,9 @@ packages:
|
|||
resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
openid-client@6.6.4:
|
||||
resolution: {integrity: sha512-PLWVhRksRnNH05sqeuCX/PR+1J70NyZcAcPske+FeF732KKONd3v0p5Utx1ro1iLfCglH8B3/+dA1vqIHDoIiA==}
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
|
@ -11599,6 +11611,8 @@ snapshots:
|
|||
- supports-color
|
||||
- ts-node
|
||||
|
||||
jose@6.0.12: {}
|
||||
|
||||
joycon@3.1.1: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
|
@ -12135,6 +12149,8 @@ snapshots:
|
|||
dependencies:
|
||||
boolbase: 1.0.0
|
||||
|
||||
oauth4webapi@3.7.0: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
|
|
@ -12189,6 +12205,11 @@ snapshots:
|
|||
dependencies:
|
||||
mimic-fn: 4.0.0
|
||||
|
||||
openid-client@6.6.4:
|
||||
dependencies:
|
||||
jose: 6.0.12
|
||||
oauth4webapi: 3.7.0
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
|
|
|
|||
94
src/app/api/auth/login/oidc/route.ts
Normal file
94
src/app/api/auth/login/oidc/route.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import * as oidcClient from 'openid-client';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { createSecureToken } from '@/lib/jwt';
|
||||
import { createUser, getUserByUsername } from '@/queries';
|
||||
import { json } from '@/lib/response';
|
||||
import redis from '@/lib/redis';
|
||||
import { saveAuth } from '@/lib/auth';
|
||||
import { secret, uuid } from '@/lib/crypto';
|
||||
import { ROLES } from '@/lib/constants';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { createOidcState, getOidcState } from '@/queries/prisma/oidc';
|
||||
|
||||
const oidcConfig = await oidcClient.discovery(
|
||||
new URL(process.env.OIDC_ISSUER),
|
||||
process.env.OIDC_CLIENT_ID,
|
||||
process.env.OIDC_CLIENT_SECRET,
|
||||
);
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const state = req.nextUrl.searchParams.get('state');
|
||||
if (state) {
|
||||
const { codeVerifier } = await getOidcState(state);
|
||||
|
||||
const tokens = await oidcClient.authorizationCodeGrant(oidcConfig, req, {
|
||||
pkceCodeVerifier: codeVerifier,
|
||||
expectedState: state,
|
||||
});
|
||||
|
||||
const profile = await oidcClient.fetchUserInfo(
|
||||
oidcConfig,
|
||||
tokens.access_token,
|
||||
tokens.claims().sub,
|
||||
);
|
||||
|
||||
// TODO - have a mapping table of profile.sub => username so no conflicts or claiming existing accounts
|
||||
// TODO - make preferred_username configurable
|
||||
let user = await getUserByUsername(profile.preferred_username);
|
||||
|
||||
if (!user) {
|
||||
user = await createUser({
|
||||
id: uuid(),
|
||||
username: profile.preferred_username,
|
||||
password: '',
|
||||
role: ROLES.user,
|
||||
});
|
||||
}
|
||||
|
||||
const { id, role, createdAt } = user;
|
||||
|
||||
let token: string;
|
||||
|
||||
if (redis.enabled) {
|
||||
token = await saveAuth({ userId: id, role });
|
||||
} else {
|
||||
token = createSecureToken({ userId: user.id, role }, secret());
|
||||
}
|
||||
|
||||
return json({
|
||||
token,
|
||||
user: { id, username: user.username, role, createdAt, isAdmin: role === ROLES.admin },
|
||||
});
|
||||
} else {
|
||||
const redirectUri: string = process.env.OIDC_RETURN_URL;
|
||||
//const redirectUri = 'http://localhost:3000/api/auth/login/oidc';
|
||||
const scope: string = 'openid profile email';
|
||||
const oidcCodeVerifier: string = oidcClient.randomPKCECodeVerifier();
|
||||
const codeChallenge: string = await oidcClient.calculatePKCECodeChallenge(oidcCodeVerifier);
|
||||
let state: string = uuid();
|
||||
|
||||
const parameters: Record<string, string> = {
|
||||
redirect_uri: redirectUri,
|
||||
state,
|
||||
scope,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
};
|
||||
|
||||
if (!oidcConfig.serverMetadata().supportsPKCE()) {
|
||||
/**
|
||||
* We cannot be sure the server supports PKCE so we're going to use state too.
|
||||
* Use of PKCE is backwards compatible even if the AS doesn't support it which
|
||||
* is why we're using it regardless. Like PKCE, random state must be generated
|
||||
* for every redirect to the authorization_endpoint.
|
||||
*/
|
||||
state = oidcClient.randomState();
|
||||
parameters.state = state;
|
||||
}
|
||||
|
||||
await createOidcState({ state, codeVerifier: oidcCodeVerifier });
|
||||
|
||||
const redirectTo: URL = oidcClient.buildAuthorizationUrl(oidcConfig, parameters);
|
||||
redirect(redirectTo.toString());
|
||||
}
|
||||
}
|
||||
25
src/app/login/oidc/callback/page.tsx
Normal file
25
src/app/login/oidc/callback/page.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
'use client';
|
||||
|
||||
import { setClientAuthToken } from '@/lib/client';
|
||||
import { httpGet } from '@/lib/fetch';
|
||||
import { setUser } from '@/store/app';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function OidcCallbackPage() {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
httpGet('/api/auth/login/oidc', Object.fromEntries(params.entries()))
|
||||
.then(response => {
|
||||
setClientAuthToken(response.data.token);
|
||||
setUser(response.data.user);
|
||||
|
||||
router.push('/dashboard');
|
||||
})
|
||||
.catch(err => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
});
|
||||
}, []);
|
||||
}
|
||||
9
src/app/login/oidc/page.tsx
Normal file
9
src/app/login/oidc/page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function OidcLoginPage() {
|
||||
if (!process.env.ENABLE_OIDC) {
|
||||
redirect('/login');
|
||||
} else {
|
||||
redirect('/api/auth/login/oidc');
|
||||
}
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ const selector = (state: { shareToken: { token?: string } }) => state.shareToken
|
|||
|
||||
async function handleResponse(res: FetchResponse): Promise<any> {
|
||||
if (!res.ok) {
|
||||
return Promise.reject(new Error(res.error?.error || res.error || 'Unexpectd error.'));
|
||||
return Promise.reject(new Error(res.error?.error || res.error || 'Unexpected error.'));
|
||||
}
|
||||
return Promise.resolve(res.data);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ export function hashPassword(password: string, rounds = SALT_ROUNDS) {
|
|||
}
|
||||
|
||||
export function checkPassword(password: string, passwordHash: string) {
|
||||
if (password === '' || passwordHash === '') {
|
||||
return false;
|
||||
}
|
||||
return bcrypt.compareSync(password, passwordHash);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -436,7 +436,7 @@ function getClient(params?: {
|
|||
return prisma;
|
||||
}
|
||||
|
||||
const client = global[PRISMA] || getClient();
|
||||
const client: PrismaClient = global[PRISMA] || getClient();
|
||||
|
||||
export default {
|
||||
client,
|
||||
|
|
|
|||
31
src/queries/prisma/oidc.ts
Normal file
31
src/queries/prisma/oidc.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import prisma from '@/lib/prisma';
|
||||
|
||||
export async function createOidcState(data: { state: string; codeVerifier: string }): Promise<{
|
||||
state: string;
|
||||
codeVerifier: string;
|
||||
}> {
|
||||
return prisma.client.oidcState.create({
|
||||
data,
|
||||
select: {
|
||||
state: true,
|
||||
codeVerifier: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOidcState(state: string): Promise<{
|
||||
state: string;
|
||||
codeVerifier: string;
|
||||
createdAt: Date;
|
||||
} | null> {
|
||||
return prisma.client.oidcState.findUnique({
|
||||
where: {
|
||||
state: state,
|
||||
},
|
||||
select: {
|
||||
state: true,
|
||||
codeVerifier: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue