mirror of
https://github.com/umami-software/umami.git
synced 2026-02-17 19:15:37 +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,
|
||||
} from 'react-basics';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useApi, useMessages } from '@/components/hooks';
|
||||
import { setUser } from '@/store/app';
|
||||
import { setClientAuthToken } from '@/lib/client';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import Logo from '@/assets/logo.svg';
|
||||
import styles from './LoginForm.module.css';
|
||||
import { signIn } from 'next-auth/react';
|
||||
|
||||
export function LoginForm() {
|
||||
const { formatMessage, labels, getMessage } = useMessages();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const router = useRouter();
|
||||
const { post, useMutation } = useApi();
|
||||
const { mutate, error, isPending } = useMutation({
|
||||
mutationFn: (data: any) => post('/auth/login', data),
|
||||
});
|
||||
|
||||
const handleSubmit = async (data: any) => {
|
||||
mutate(data, {
|
||||
onSuccess: async ({ token, user }) => {
|
||||
setClientAuthToken(token);
|
||||
setUser(user);
|
||||
|
||||
router.push('/dashboard');
|
||||
},
|
||||
const res = await signIn('credentials', {
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (res?.error) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
router.push('/dashboard');
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -40,14 +38,14 @@ export function LoginForm() {
|
|||
<Logo />
|
||||
</Icon>
|
||||
<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)}>
|
||||
<FormInput
|
||||
data-test="input-username"
|
||||
name="username"
|
||||
rules={{ required: formatMessage(labels.required) }}
|
||||
>
|
||||
<TextField autoComplete="off" />
|
||||
<TextField autoComplete="username" />
|
||||
</FormInput>
|
||||
</FormRow>
|
||||
<FormRow label={formatMessage(labels.password)}>
|
||||
|
|
@ -56,16 +54,11 @@ export function LoginForm() {
|
|||
name="password"
|
||||
rules={{ required: formatMessage(labels.required) }}
|
||||
>
|
||||
<PasswordField />
|
||||
<PasswordField autoComplete="current-password" />
|
||||
</FormInput>
|
||||
</FormRow>
|
||||
<FormButtons>
|
||||
<SubmitButton
|
||||
data-test="button-submit"
|
||||
className={styles.button}
|
||||
variant="primary"
|
||||
disabled={isPending}
|
||||
>
|
||||
<SubmitButton data-test="button-submit" className={styles.button} variant="primary">
|
||||
{formatMessage(labels.login)}
|
||||
</SubmitButton>
|
||||
</FormButtons>
|
||||
|
|
|
|||
|
|
@ -1,30 +1,22 @@
|
|||
'use client';
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useApi } from '@/components/hooks';
|
||||
import { setUser } from '@/store/app';
|
||||
import { removeClientAuthToken } from '@/lib/client';
|
||||
import { signOut } from 'next-auth/react';
|
||||
|
||||
export function LogoutPage() {
|
||||
const router = useRouter();
|
||||
const { post } = useApi();
|
||||
const disabled = process.env.cloudMode;
|
||||
|
||||
useEffect(() => {
|
||||
async function logout() {
|
||||
await post('/auth/logout');
|
||||
await signOut({ redirect: false });
|
||||
}
|
||||
|
||||
if (!disabled) {
|
||||
removeClientAuthToken();
|
||||
|
||||
logout();
|
||||
|
||||
router.push('/login');
|
||||
|
||||
return () => setUser(null);
|
||||
}
|
||||
}, [disabled, router, post]);
|
||||
}, [disabled, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
import { useEffect } from 'react';
|
||||
import { Loading } from 'react-basics';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { setClientAuthToken } from '@/lib/client';
|
||||
|
||||
export default function SSOPage() {
|
||||
const router = useRouter();
|
||||
|
|
@ -12,8 +11,6 @@ export default function SSOPage() {
|
|||
|
||||
useEffect(() => {
|
||||
if (url && token) {
|
||||
setClientAuthToken(token);
|
||||
|
||||
router.push(url);
|
||||
}
|
||||
}, [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 * as reactQuery from '@tanstack/react-query';
|
||||
import { getClientAuthToken } from '@/lib/client';
|
||||
import { SHARE_TOKEN_HEADER } from '@/lib/constants';
|
||||
import { httpGet, httpPost, httpPut, httpDelete, FetchResponse } from '@/lib/fetch';
|
||||
import useStore from '@/store/app';
|
||||
|
|
@ -22,9 +21,8 @@ export function useApi() {
|
|||
const shareToken = useStore(selector);
|
||||
|
||||
const defaultHeaders = {
|
||||
authorization: `Bearer ${getClientAuthToken()}`,
|
||||
[SHARE_TOKEN_HEADER]: shareToken?.token,
|
||||
};
|
||||
} as any;
|
||||
const basePath = process.env.basePath;
|
||||
|
||||
const getUrl = (url: string) => {
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import redis from '@/lib/redis';
|
|||
import debug from 'debug';
|
||||
import { PERMISSIONS, ROLE_PERMISSIONS, ROLES, SHARE_TOKEN_HEADER } from '@/lib/constants';
|
||||
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 { getTeamUser, getUser, getWebsite } from '@/queries';
|
||||
import { Auth } from './types';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import authOptions from '@/lib/authOptions';
|
||||
|
||||
const log = debug('umami:auth');
|
||||
const cloudMode = process.env.CLOUD_MODE;
|
||||
|
|
@ -22,25 +24,20 @@ export function checkPassword(password: string, passwordHash: string) {
|
|||
}
|
||||
|
||||
export async function checkAuth(request: Request) {
|
||||
const token = request.headers.get('authorization')?.split(' ')?.[1];
|
||||
const payload = parseSecureToken(token, secret());
|
||||
const session = await getServerSession(authOptions);
|
||||
const shareToken = await parseShareToken(request.headers);
|
||||
|
||||
let user = null;
|
||||
const { userId, authKey, grant } = payload || {};
|
||||
let user: any = null;
|
||||
const grant = undefined as any;
|
||||
const token = undefined as any;
|
||||
const authKey = undefined as any;
|
||||
|
||||
if (userId) {
|
||||
user = await getUser(userId);
|
||||
} else if (redis.enabled && authKey) {
|
||||
const key = await redis.client.get(authKey);
|
||||
|
||||
if (key?.userId) {
|
||||
user = await getUser(key.userId);
|
||||
}
|
||||
if ((session as any)?.user?.id) {
|
||||
user = await getUser((session as any).user.id as string);
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
log('checkAuth:', { token, shareToken, payload, user, grant });
|
||||
log('checkAuth:', { session, shareToken, user, grant });
|
||||
}
|
||||
|
||||
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