mirror of
https://github.com/umami-software/umami.git
synced 2026-02-18 03:25:40 +01:00
Refactor authentication flow in LoginForm and LogoutPage to use next-auth. Remove unnecessary API calls and improve form handling. Update SSOPage to eliminate client token management. Adjust useApi hook to remove client token dependency.
This commit is contained in:
parent
1c4c97e02a
commit
610ca29cb5
11 changed files with 122 additions and 55 deletions
14
db/postgresql/migrations/14_add_auth_columns/migration.sql
Normal file
14
db/postgresql/migrations/14_add_auth_columns/migration.sql
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
-- Add optional email and email_verified to user table for Auth.js compatibility
|
||||||
|
ALTER TABLE "user"
|
||||||
|
ADD COLUMN IF NOT EXISTS email varchar(255),
|
||||||
|
ADD COLUMN IF NOT EXISTS email_verified timestamptz;
|
||||||
|
|
||||||
|
-- Ensure email is unique if present
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_indexes WHERE schemaname = current_schema() AND indexname = 'user_email_key'
|
||||||
|
) THEN
|
||||||
|
CREATE UNIQUE INDEX user_email_key ON "user" (email);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
6
src/app/api/auth/[...nextauth]/route.ts
Normal file
6
src/app/api/auth/[...nextauth]/route.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import NextAuth from 'next-auth';
|
||||||
|
import authOptions from '@/lib/authOptions';
|
||||||
|
|
||||||
|
const handler = NextAuth(authOptions);
|
||||||
|
|
||||||
|
export { handler as GET, handler as POST };
|
||||||
|
|
@ -9,29 +9,27 @@ import {
|
||||||
Icon,
|
Icon,
|
||||||
} from 'react-basics';
|
} from 'react-basics';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useApi, useMessages } from '@/components/hooks';
|
import { useMessages } from '@/components/hooks';
|
||||||
import { setUser } from '@/store/app';
|
|
||||||
import { setClientAuthToken } from '@/lib/client';
|
|
||||||
import Logo from '@/assets/logo.svg';
|
import Logo from '@/assets/logo.svg';
|
||||||
import styles from './LoginForm.module.css';
|
import styles from './LoginForm.module.css';
|
||||||
|
import { signIn } from 'next-auth/react';
|
||||||
|
|
||||||
export function LoginForm() {
|
export function LoginForm() {
|
||||||
const { formatMessage, labels, getMessage } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { post, useMutation } = useApi();
|
|
||||||
const { mutate, error, isPending } = useMutation({
|
|
||||||
mutationFn: (data: any) => post('/auth/login', data),
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = async (data: any) => {
|
const handleSubmit = async (data: any) => {
|
||||||
mutate(data, {
|
const res = await signIn('credentials', {
|
||||||
onSuccess: async ({ token, user }) => {
|
username: data.username,
|
||||||
setClientAuthToken(token);
|
password: data.password,
|
||||||
setUser(user);
|
redirect: false,
|
||||||
|
|
||||||
router.push('/dashboard');
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (res?.error) {
|
||||||
|
throw new Error(res.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push('/dashboard');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -40,14 +38,14 @@ export function LoginForm() {
|
||||||
<Logo />
|
<Logo />
|
||||||
</Icon>
|
</Icon>
|
||||||
<div className={styles.title}>umami</div>
|
<div className={styles.title}>umami</div>
|
||||||
<Form className={styles.form} onSubmit={handleSubmit} error={getMessage(error)}>
|
<Form className={styles.form} onSubmit={handleSubmit}>
|
||||||
<FormRow label={formatMessage(labels.username)}>
|
<FormRow label={formatMessage(labels.username)}>
|
||||||
<FormInput
|
<FormInput
|
||||||
data-test="input-username"
|
data-test="input-username"
|
||||||
name="username"
|
name="username"
|
||||||
rules={{ required: formatMessage(labels.required) }}
|
rules={{ required: formatMessage(labels.required) }}
|
||||||
>
|
>
|
||||||
<TextField autoComplete="off" />
|
<TextField autoComplete="username" />
|
||||||
</FormInput>
|
</FormInput>
|
||||||
</FormRow>
|
</FormRow>
|
||||||
<FormRow label={formatMessage(labels.password)}>
|
<FormRow label={formatMessage(labels.password)}>
|
||||||
|
|
@ -56,16 +54,11 @@ export function LoginForm() {
|
||||||
name="password"
|
name="password"
|
||||||
rules={{ required: formatMessage(labels.required) }}
|
rules={{ required: formatMessage(labels.required) }}
|
||||||
>
|
>
|
||||||
<PasswordField />
|
<PasswordField autoComplete="current-password" />
|
||||||
</FormInput>
|
</FormInput>
|
||||||
</FormRow>
|
</FormRow>
|
||||||
<FormButtons>
|
<FormButtons>
|
||||||
<SubmitButton
|
<SubmitButton data-test="button-submit" className={styles.button} variant="primary">
|
||||||
data-test="button-submit"
|
|
||||||
className={styles.button}
|
|
||||||
variant="primary"
|
|
||||||
disabled={isPending}
|
|
||||||
>
|
|
||||||
{formatMessage(labels.login)}
|
{formatMessage(labels.login)}
|
||||||
</SubmitButton>
|
</SubmitButton>
|
||||||
</FormButtons>
|
</FormButtons>
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,22 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useApi } from '@/components/hooks';
|
import { signOut } from 'next-auth/react';
|
||||||
import { setUser } from '@/store/app';
|
|
||||||
import { removeClientAuthToken } from '@/lib/client';
|
|
||||||
|
|
||||||
export function LogoutPage() {
|
export function LogoutPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { post } = useApi();
|
|
||||||
const disabled = process.env.cloudMode;
|
const disabled = process.env.cloudMode;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function logout() {
|
async function logout() {
|
||||||
await post('/auth/logout');
|
await signOut({ redirect: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!disabled) {
|
if (!disabled) {
|
||||||
removeClientAuthToken();
|
|
||||||
|
|
||||||
logout();
|
logout();
|
||||||
|
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
|
|
||||||
return () => setUser(null);
|
|
||||||
}
|
}
|
||||||
}, [disabled, router, post]);
|
}, [disabled, router]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Loading } from 'react-basics';
|
import { Loading } from 'react-basics';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { setClientAuthToken } from '@/lib/client';
|
|
||||||
|
|
||||||
export default function SSOPage() {
|
export default function SSOPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -12,8 +11,6 @@ export default function SSOPage() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (url && token) {
|
if (url && token) {
|
||||||
setClientAuthToken(token);
|
|
||||||
|
|
||||||
router.push(url);
|
router.push(url);
|
||||||
}
|
}
|
||||||
}, [router, url, token]);
|
}, [router, url, token]);
|
||||||
|
|
|
||||||
2
src/auth.ts
Normal file
2
src/auth.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
// Auth.js initialization lives in src/lib/authOptions.ts and app/api/auth/[...nextauth]/route.ts
|
||||||
|
// This file is intentionally left empty to avoid duplicate initialization.
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import * as reactQuery from '@tanstack/react-query';
|
import * as reactQuery from '@tanstack/react-query';
|
||||||
import { getClientAuthToken } from '@/lib/client';
|
|
||||||
import { SHARE_TOKEN_HEADER } from '@/lib/constants';
|
import { SHARE_TOKEN_HEADER } from '@/lib/constants';
|
||||||
import { httpGet, httpPost, httpPut, httpDelete, FetchResponse } from '@/lib/fetch';
|
import { httpGet, httpPost, httpPut, httpDelete, FetchResponse } from '@/lib/fetch';
|
||||||
import useStore from '@/store/app';
|
import useStore from '@/store/app';
|
||||||
|
|
@ -22,9 +21,8 @@ export function useApi() {
|
||||||
const shareToken = useStore(selector);
|
const shareToken = useStore(selector);
|
||||||
|
|
||||||
const defaultHeaders = {
|
const defaultHeaders = {
|
||||||
authorization: `Bearer ${getClientAuthToken()}`,
|
|
||||||
[SHARE_TOKEN_HEADER]: shareToken?.token,
|
[SHARE_TOKEN_HEADER]: shareToken?.token,
|
||||||
};
|
} as any;
|
||||||
const basePath = process.env.basePath;
|
const basePath = process.env.basePath;
|
||||||
|
|
||||||
const getUrl = (url: string) => {
|
const getUrl = (url: string) => {
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,12 @@ import redis from '@/lib/redis';
|
||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
import { PERMISSIONS, ROLE_PERMISSIONS, ROLES, SHARE_TOKEN_HEADER } from '@/lib/constants';
|
import { PERMISSIONS, ROLE_PERMISSIONS, ROLES, SHARE_TOKEN_HEADER } from '@/lib/constants';
|
||||||
import { secret, getRandomChars } from '@/lib/crypto';
|
import { secret, getRandomChars } from '@/lib/crypto';
|
||||||
import { createSecureToken, parseSecureToken, parseToken } from '@/lib/jwt';
|
import { createSecureToken, parseToken } from '@/lib/jwt';
|
||||||
import { ensureArray } from '@/lib/utils';
|
import { ensureArray } from '@/lib/utils';
|
||||||
import { getTeamUser, getUser, getWebsite } from '@/queries';
|
import { getTeamUser, getUser, getWebsite } from '@/queries';
|
||||||
import { Auth } from './types';
|
import { Auth } from './types';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import authOptions from '@/lib/authOptions';
|
||||||
|
|
||||||
const log = debug('umami:auth');
|
const log = debug('umami:auth');
|
||||||
const cloudMode = process.env.CLOUD_MODE;
|
const cloudMode = process.env.CLOUD_MODE;
|
||||||
|
|
@ -22,25 +24,20 @@ export function checkPassword(password: string, passwordHash: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkAuth(request: Request) {
|
export async function checkAuth(request: Request) {
|
||||||
const token = request.headers.get('authorization')?.split(' ')?.[1];
|
const session = await getServerSession(authOptions);
|
||||||
const payload = parseSecureToken(token, secret());
|
|
||||||
const shareToken = await parseShareToken(request.headers);
|
const shareToken = await parseShareToken(request.headers);
|
||||||
|
|
||||||
let user = null;
|
let user: any = null;
|
||||||
const { userId, authKey, grant } = payload || {};
|
const grant = undefined as any;
|
||||||
|
const token = undefined as any;
|
||||||
|
const authKey = undefined as any;
|
||||||
|
|
||||||
if (userId) {
|
if ((session as any)?.user?.id) {
|
||||||
user = await getUser(userId);
|
user = await getUser((session as any).user.id as string);
|
||||||
} else if (redis.enabled && authKey) {
|
|
||||||
const key = await redis.client.get(authKey);
|
|
||||||
|
|
||||||
if (key?.userId) {
|
|
||||||
user = await getUser(key.userId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
log('checkAuth:', { token, shareToken, payload, user, grant });
|
log('checkAuth:', { session, shareToken, user, grant });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user?.id && !shareToken) {
|
if (!user?.id && !shareToken) {
|
||||||
|
|
|
||||||
46
src/lib/authOptions.ts
Normal file
46
src/lib/authOptions.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import type { NextAuthOptions } from 'next-auth';
|
||||||
|
import CredentialsProvider from 'next-auth/providers/credentials';
|
||||||
|
import { checkPassword } from '@/lib/auth';
|
||||||
|
import { getUserByUsername } from '@/queries';
|
||||||
|
|
||||||
|
const AUTH_SECRET = process.env.NEXTAUTH_SECRET || process.env.APP_SECRET;
|
||||||
|
|
||||||
|
const authOptions: NextAuthOptions = {
|
||||||
|
secret: AUTH_SECRET,
|
||||||
|
session: { strategy: 'jwt' },
|
||||||
|
providers: [
|
||||||
|
CredentialsProvider({
|
||||||
|
name: 'Credentials',
|
||||||
|
credentials: {
|
||||||
|
username: { label: 'Username', type: 'text' },
|
||||||
|
password: { label: 'Password', type: 'password' },
|
||||||
|
},
|
||||||
|
authorize: async credentials => {
|
||||||
|
if (!credentials?.username || !credentials?.password) return null;
|
||||||
|
const user = await getUserByUsername(credentials.username, {
|
||||||
|
includePassword: true,
|
||||||
|
} as any);
|
||||||
|
if (!user) return null;
|
||||||
|
const ok = checkPassword(credentials.password, user.password as string);
|
||||||
|
if (!ok) return null;
|
||||||
|
return { id: user.id, name: user.username, image: undefined, role: user.role } as any;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
callbacks: {
|
||||||
|
async session({ session, token }) {
|
||||||
|
(session as any).user.id = (token as any).id as string;
|
||||||
|
(session as any).user.role = (token as any).role as string;
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
async jwt({ token, user }) {
|
||||||
|
if (user) {
|
||||||
|
(token as any).id = (user as any).id;
|
||||||
|
(token as any).role = (user as any).role;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default authOptions;
|
||||||
9
src/middleware.ts
Normal file
9
src/middleware.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export function middleware() {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
|
||||||
|
};
|
||||||
13
src/types/next-auth.d.ts
vendored
Normal file
13
src/types/next-auth.d.ts
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import 'next-auth';
|
||||||
|
|
||||||
|
declare module 'next-auth' {
|
||||||
|
interface Session {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
name?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
image?: string | null;
|
||||||
|
role?: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue