Initial commit: Portfolio Website
Vollständige Next.js 15 Portfolio-Website mit: - Blog-System mit 100+ Artikeln - Supabase-Integration - Responsive Design mit Tailwind CSS - TypeScript-Konfiguration - Testing-Setup mit Vitest und Playwright Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,559 @@
|
||||
# Authentication Patterns für moderne Apps
|
||||
|
||||
**Meta-Description:** Moderne Authentication Patterns. JWT, Session Auth, OAuth 2.0, Passkeys und Auth.js Integration für Next.js.
|
||||
|
||||
**Keywords:** Authentication, JWT, OAuth, Passkeys, Session Auth, Auth.js, NextAuth, Security, OIDC
|
||||
|
||||
---
|
||||
|
||||
## Einführung
|
||||
|
||||
Authentication ist die erste Verteidigungslinie jeder Anwendung. 2026 stehen mehrere Patterns zur Verfügung: **JWT, Sessions, OAuth, Passkeys** – jedes mit eigenen Trade-offs für Security und UX.
|
||||
|
||||
---
|
||||
|
||||
## Auth Patterns Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ AUTHENTICATION PATTERNS │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Session-Based (Stateful): │
|
||||
│ ├── Server speichert Session │
|
||||
│ ├── Cookie mit Session ID │
|
||||
│ ├── Einfach zu invalidieren │
|
||||
│ └── Best for: Traditional Web Apps │
|
||||
│ │
|
||||
│ JWT (Stateless): │
|
||||
│ ├── Self-contained Token │
|
||||
│ ├── Keine Server-Speicherung │
|
||||
│ ├── Schwer zu invalidieren │
|
||||
│ └── Best for: APIs, Microservices │
|
||||
│ │
|
||||
│ OAuth 2.0 / OIDC: │
|
||||
│ ├── Delegierte Authentifizierung │
|
||||
│ ├── Google, GitHub, etc. │
|
||||
│ ├── Refresh Token Flow │
|
||||
│ └── Best for: Social Login │
|
||||
│ │
|
||||
│ Passkeys (WebAuthn): │
|
||||
│ ├── Passwordless │
|
||||
│ ├── Biometric/Device-based │
|
||||
│ ├── Phishing-resistant │
|
||||
│ └── Best for: High Security, Modern UX │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Auth.js (NextAuth) v5 Setup
|
||||
|
||||
```bash
|
||||
npm install next-auth@beta
|
||||
```
|
||||
|
||||
```typescript
|
||||
// auth.ts
|
||||
import NextAuth from 'next-auth';
|
||||
import GitHub from 'next-auth/providers/github';
|
||||
import Google from 'next-auth/providers/google';
|
||||
import Credentials from 'next-auth/providers/credentials';
|
||||
import { PrismaAdapter } from '@auth/prisma-adapter';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { z } from 'zod';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
adapter: PrismaAdapter(prisma),
|
||||
providers: [
|
||||
GitHub({
|
||||
clientId: process.env.GITHUB_ID!,
|
||||
clientSecret: process.env.GITHUB_SECRET!
|
||||
}),
|
||||
Google({
|
||||
clientId: process.env.GOOGLE_ID!,
|
||||
clientSecret: process.env.GOOGLE_SECRET!
|
||||
}),
|
||||
Credentials({
|
||||
credentials: {
|
||||
email: { label: 'Email', type: 'email' },
|
||||
password: { label: 'Password', type: 'password' }
|
||||
},
|
||||
async authorize(credentials) {
|
||||
const parsed = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8)
|
||||
}).safeParse(credentials);
|
||||
|
||||
if (!parsed.success) return null;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: parsed.data.email }
|
||||
});
|
||||
|
||||
if (!user?.password) return null;
|
||||
|
||||
const valid = await bcrypt.compare(
|
||||
parsed.data.password,
|
||||
user.password
|
||||
);
|
||||
|
||||
if (!valid) return null;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name
|
||||
};
|
||||
}
|
||||
})
|
||||
],
|
||||
session: {
|
||||
strategy: 'jwt' // oder 'database'
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
token.role = user.role;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
session.user.id = token.id as string;
|
||||
session.user.role = token.role as string;
|
||||
return session;
|
||||
}
|
||||
},
|
||||
pages: {
|
||||
signIn: '/login',
|
||||
error: '/login'
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// app/api/auth/[...nextauth]/route.ts
|
||||
import { handlers } from '@/auth';
|
||||
export const { GET, POST } = handlers;
|
||||
|
||||
// middleware.ts
|
||||
import { auth } from '@/auth';
|
||||
|
||||
export default auth((req) => {
|
||||
if (!req.auth && req.nextUrl.pathname.startsWith('/dashboard')) {
|
||||
return Response.redirect(new URL('/login', req.nextUrl));
|
||||
}
|
||||
});
|
||||
|
||||
export const config = {
|
||||
matcher: ['/dashboard/:path*', '/api/protected/:path*']
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## JWT Implementation (Custom)
|
||||
|
||||
```typescript
|
||||
// lib/jwt.ts
|
||||
import { SignJWT, jwtVerify } from 'jose';
|
||||
|
||||
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
|
||||
|
||||
interface TokenPayload {
|
||||
userId: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
// Access Token (kurze Lebenszeit)
|
||||
export async function createAccessToken(payload: TokenPayload) {
|
||||
return new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('15m') // 15 Minuten
|
||||
.sign(secret);
|
||||
}
|
||||
|
||||
// Refresh Token (lange Lebenszeit)
|
||||
export async function createRefreshToken(userId: string) {
|
||||
return new SignJWT({ userId })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('7d') // 7 Tage
|
||||
.sign(secret);
|
||||
}
|
||||
|
||||
export async function verifyToken(token: string) {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, secret);
|
||||
return payload as TokenPayload & { exp: number };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Token Rotation Pattern
|
||||
export async function refreshTokens(refreshToken: string) {
|
||||
const payload = await verifyToken(refreshToken);
|
||||
if (!payload) throw new Error('Invalid refresh token');
|
||||
|
||||
// Optional: Refresh Token im DB invalidieren (Rotation)
|
||||
await db.refreshToken.delete({
|
||||
where: { token: refreshToken }
|
||||
});
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: payload.userId }
|
||||
});
|
||||
|
||||
if (!user) throw new Error('User not found');
|
||||
|
||||
const newAccessToken = await createAccessToken({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role: user.role
|
||||
});
|
||||
|
||||
const newRefreshToken = await createRefreshToken(user.id);
|
||||
|
||||
// Neuen Refresh Token speichern
|
||||
await db.refreshToken.create({
|
||||
data: {
|
||||
token: newRefreshToken,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
||||
}
|
||||
});
|
||||
|
||||
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Passkeys (WebAuthn)
|
||||
|
||||
```typescript
|
||||
// lib/passkeys.ts
|
||||
import {
|
||||
generateRegistrationOptions,
|
||||
verifyRegistrationResponse,
|
||||
generateAuthenticationOptions,
|
||||
verifyAuthenticationResponse
|
||||
} from '@simplewebauthn/server';
|
||||
|
||||
const rpName = 'My App';
|
||||
const rpID = 'example.com';
|
||||
const origin = 'https://example.com';
|
||||
|
||||
// Registration
|
||||
export async function startPasskeyRegistration(userId: string, email: string) {
|
||||
const existingCredentials = await db.credential.findMany({
|
||||
where: { userId }
|
||||
});
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName,
|
||||
rpID,
|
||||
userID: userId,
|
||||
userName: email,
|
||||
attestationType: 'none',
|
||||
excludeCredentials: existingCredentials.map(c => ({
|
||||
id: c.credentialId,
|
||||
type: 'public-key'
|
||||
})),
|
||||
authenticatorSelection: {
|
||||
residentKey: 'preferred',
|
||||
userVerification: 'preferred'
|
||||
}
|
||||
});
|
||||
|
||||
// Challenge speichern
|
||||
await db.user.update({
|
||||
where: { id: userId },
|
||||
data: { currentChallenge: options.challenge }
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function finishPasskeyRegistration(
|
||||
userId: string,
|
||||
response: RegistrationResponseJSON
|
||||
) {
|
||||
const user = await db.user.findUnique({ where: { id: userId } });
|
||||
if (!user?.currentChallenge) throw new Error('No challenge');
|
||||
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge: user.currentChallenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID
|
||||
});
|
||||
|
||||
if (verification.verified && verification.registrationInfo) {
|
||||
await db.credential.create({
|
||||
data: {
|
||||
userId,
|
||||
credentialId: verification.registrationInfo.credentialID,
|
||||
publicKey: Buffer.from(verification.registrationInfo.credentialPublicKey),
|
||||
counter: verification.registrationInfo.counter
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return verification.verified;
|
||||
}
|
||||
|
||||
// Authentication
|
||||
export async function startPasskeyAuth(email: string) {
|
||||
const user = await db.user.findUnique({
|
||||
where: { email },
|
||||
include: { credentials: true }
|
||||
});
|
||||
|
||||
if (!user) throw new Error('User not found');
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID,
|
||||
allowCredentials: user.credentials.map(c => ({
|
||||
id: c.credentialId,
|
||||
type: 'public-key'
|
||||
})),
|
||||
userVerification: 'preferred'
|
||||
});
|
||||
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: { currentChallenge: options.challenge }
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function finishPasskeyAuth(
|
||||
email: string,
|
||||
response: AuthenticationResponseJSON
|
||||
) {
|
||||
const user = await db.user.findUnique({
|
||||
where: { email },
|
||||
include: { credentials: true }
|
||||
});
|
||||
|
||||
if (!user?.currentChallenge) throw new Error('No challenge');
|
||||
|
||||
const credential = user.credentials.find(
|
||||
c => c.credentialId === response.id
|
||||
);
|
||||
|
||||
if (!credential) throw new Error('Unknown credential');
|
||||
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge: user.currentChallenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID,
|
||||
authenticator: {
|
||||
credentialID: credential.credentialId,
|
||||
credentialPublicKey: credential.publicKey,
|
||||
counter: credential.counter
|
||||
}
|
||||
});
|
||||
|
||||
if (verification.verified) {
|
||||
// Counter updaten
|
||||
await db.credential.update({
|
||||
where: { id: credential.id },
|
||||
data: { counter: verification.authenticationInfo.newCounter }
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
throw new Error('Verification failed');
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Client-Side (React)
|
||||
'use client';
|
||||
|
||||
import { startRegistration, startAuthentication } from '@simplewebauthn/browser';
|
||||
|
||||
function PasskeyLogin() {
|
||||
const handleRegister = async () => {
|
||||
const options = await fetch('/api/passkey/register/start', {
|
||||
method: 'POST'
|
||||
}).then(r => r.json());
|
||||
|
||||
const result = await startRegistration(options);
|
||||
|
||||
await fetch('/api/passkey/register/finish', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(result)
|
||||
});
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
const options = await fetch('/api/passkey/login/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email })
|
||||
}).then(r => r.json());
|
||||
|
||||
const result = await startAuthentication(options);
|
||||
|
||||
await fetch('/api/passkey/login/finish', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(result)
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={handleRegister}>Register Passkey</button>
|
||||
<button onClick={handleLogin}>Login with Passkey</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Magic Links
|
||||
|
||||
```typescript
|
||||
// Magic Link / Email Auth
|
||||
import { Resend } from 'resend';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
|
||||
export async function sendMagicLink(email: string) {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const expires = new Date(Date.now() + 15 * 60 * 1000); // 15 min
|
||||
|
||||
await db.verificationToken.create({
|
||||
data: {
|
||||
identifier: email,
|
||||
token: await hash(token),
|
||||
expires
|
||||
}
|
||||
});
|
||||
|
||||
const url = `${process.env.NEXTAUTH_URL}/api/auth/verify?token=${token}&email=${email}`;
|
||||
|
||||
await resend.emails.send({
|
||||
from: 'noreply@example.com',
|
||||
to: email,
|
||||
subject: 'Login to My App',
|
||||
html: `
|
||||
<p>Click the link below to sign in:</p>
|
||||
<a href="${url}">Sign in to My App</a>
|
||||
<p>This link expires in 15 minutes.</p>
|
||||
`
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyMagicLink(token: string, email: string) {
|
||||
const storedToken = await db.verificationToken.findFirst({
|
||||
where: {
|
||||
identifier: email,
|
||||
expires: { gt: new Date() }
|
||||
}
|
||||
});
|
||||
|
||||
if (!storedToken || !await verify(token, storedToken.token)) {
|
||||
throw new Error('Invalid or expired token');
|
||||
}
|
||||
|
||||
await db.verificationToken.delete({
|
||||
where: { id: storedToken.id }
|
||||
});
|
||||
|
||||
const user = await db.user.upsert({
|
||||
where: { email },
|
||||
update: { emailVerified: new Date() },
|
||||
create: { email, emailVerified: new Date() }
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
```typescript
|
||||
// 1. Password Hashing
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
const SALT_ROUNDS = 12;
|
||||
|
||||
export async function hashPassword(password: string) {
|
||||
return bcrypt.hash(password, SALT_ROUNDS);
|
||||
}
|
||||
|
||||
// 2. CSRF Protection (automatisch bei Auth.js)
|
||||
|
||||
// 3. Secure Cookie Settings
|
||||
const cookieOptions = {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 7 // 7 Tage
|
||||
};
|
||||
|
||||
// 4. Rate Limiting für Auth Endpoints
|
||||
// Siehe API Design Patterns Artikel
|
||||
|
||||
// 5. Account Lockout
|
||||
async function checkLoginAttempts(email: string) {
|
||||
const attempts = await db.loginAttempt.count({
|
||||
where: {
|
||||
email,
|
||||
success: false,
|
||||
createdAt: { gt: new Date(Date.now() - 15 * 60 * 1000) }
|
||||
}
|
||||
});
|
||||
|
||||
if (attempts >= 5) {
|
||||
throw new Error('Account temporarily locked. Try again later.');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fazit
|
||||
|
||||
Authentication 2026:
|
||||
|
||||
1. **Auth.js v5**: Standard für Next.js
|
||||
2. **Passkeys**: Die Zukunft des Logins
|
||||
3. **JWT + Refresh**: Für APIs
|
||||
4. **MFA**: Immer aktivieren wenn möglich
|
||||
|
||||
Wähle basierend auf Security-Anforderungen und UX.
|
||||
|
||||
---
|
||||
|
||||
## Bildprompts
|
||||
|
||||
1. "Multiple authentication methods merging into single secure access, login concept"
|
||||
2. "Passkey biometric authentication on device, passwordless future"
|
||||
3. "Security layers protecting user identity, authentication shield"
|
||||
|
||||
---
|
||||
|
||||
## Quellen
|
||||
|
||||
- [Auth.js Documentation](https://authjs.dev/)
|
||||
- [WebAuthn Guide](https://webauthn.guide/)
|
||||
- [SimpleWebAuthn](https://simplewebauthn.dev/)
|
||||
- [OWASP Authentication Cheatsheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)
|
||||
Reference in New Issue
Block a user