This commit is contained in:
Leon Bösche
2026-01-08 13:07:07 +01:00
parent 87ee5f2ae3
commit 5cb99815a0
20 changed files with 1869 additions and 202 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -1,38 +1,243 @@
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import '../session/session_bloc.dart';
import '../session/session_event.dart';
import 'auth_event.dart'; import 'auth_event.dart';
import 'auth_state.dart'; import 'auth_state.dart';
import '../../services/api_client.dart';
class AuthBloc extends Bloc<AuthEvent, AuthState> { class AuthBloc extends Bloc<AuthEvent, AuthState> {
AuthBloc() : super(AuthInitial()) { final ApiClient apiClient;
final SessionBloc sessionBloc;
AuthBloc({required this.apiClient, required this.sessionBloc})
: super(AuthInitial()) {
on<SignupStarted>(_onSignupStarted);
on<RegistrationChallengeRequested>(_onRegistrationChallengeRequested);
on<RegistrationResponseSubmitted>(_onRegistrationResponseSubmitted);
on<LoginRequested>(_onLoginRequested); on<LoginRequested>(_onLoginRequested);
on<PasswordLoginRequested>(_onPasswordLoginRequested);
on<AuthenticationChallengeRequested>(_onAuthenticationChallengeRequested);
on<AuthenticationResponseSubmitted>(_onAuthenticationResponseSubmitted);
on<LogoutRequested>(_onLogoutRequested); on<LogoutRequested>(_onLogoutRequested);
on<CheckAuthRequested>(_onCheckAuthRequested); on<CheckAuthRequested>(_onCheckAuthRequested);
} }
void _onLoginRequested(LoginRequested event, Emitter<AuthState> emit) async { Future<void> _onSignupStarted(
emit(AuthLoading()); SignupStarted event,
// Redirect to Go auth/login Emitter<AuthState> emit,
// For web, use window.location or url_launcher ) async {
// Assume handled in UI emit(AuthLoading(message: 'Creating account...'));
emit(const AuthFailure('Redirect to login URL')); try {
final response = await apiClient.post(
'/auth/signup',
data: {
'username': event.username,
'email': event.email,
'displayName': event.displayName,
'password': event.password ?? '',
},
fromJson: (data) => data,
);
final userId = response['userId'];
emit(
SignupInProgress(
username: event.username,
email: event.email,
displayName: event.displayName,
),
);
// Trigger registration challenge request
add(RegistrationChallengeRequested(userId: userId));
} catch (e) {
emit(AuthFailure(e.toString()));
}
} }
void _onLogoutRequested( Future<void> _onRegistrationChallengeRequested(
RegistrationChallengeRequested event,
Emitter<AuthState> emit,
) async {
emit(AuthLoading(message: 'Getting registration challenge...'));
try {
final response = await apiClient.post(
'/auth/registration-challenge',
data: {'userId': event.userId},
fromJson: (data) => data,
);
emit(
RegistrationChallengeReceived(
userId: event.userId,
challenge: response['challenge'],
rpName: response['rp']['name'],
rpId: response['rp']['id'],
),
);
} catch (e) {
emit(AuthFailure(e.toString()));
}
}
Future<void> _onRegistrationResponseSubmitted(
RegistrationResponseSubmitted event,
Emitter<AuthState> emit,
) async {
emit(AuthLoading(message: 'Verifying registration...'));
try {
final response = await apiClient.post(
'/auth/registration-verify',
data: {
'userId': event.userId,
'challenge': event.challenge,
'credentialId': event.credentialId,
'publicKey': event.publicKey,
'clientDataJSON': event.clientDataJSON,
'attestationObject': event.attestationObject,
},
fromJson: (data) => data,
);
final token = response['token'];
final user = response['user'];
sessionBloc.add(SessionStarted(token));
emit(
AuthAuthenticated(
token: token,
userId: user['id'],
username: user['username'],
email: user['email'],
),
);
} catch (e) {
emit(AuthFailure(e.toString()));
}
}
Future<void> _onLoginRequested(
LoginRequested event,
Emitter<AuthState> emit,
) async {
emit(AuthLoading(message: 'Requesting authentication challenge...'));
try {
add(AuthenticationChallengeRequested(username: event.username));
} catch (e) {
emit(AuthFailure(e.toString()));
}
}
Future<void> _onAuthenticationChallengeRequested(
AuthenticationChallengeRequested event,
Emitter<AuthState> emit,
) async {
try {
final response = await apiClient.post(
'/auth/authentication-challenge',
data: {'username': event.username},
fromJson: (data) => data,
);
final credentialIds =
(response['allowCredentials'] as List?)
?.map((id) => id.toString())
.toList() ??
[];
emit(
AuthenticationChallengeReceived(
challenge: response['challenge'],
credentialIds: credentialIds,
),
);
} catch (e) {
emit(AuthFailure(e.toString()));
}
}
Future<void> _onAuthenticationResponseSubmitted(
AuthenticationResponseSubmitted event,
Emitter<AuthState> emit,
) async {
emit(AuthLoading(message: 'Verifying authentication...'));
try {
final response = await apiClient.post(
'/auth/authentication-verify',
data: {
'username': event.username,
'challenge': event.challenge,
'credentialId': event.credentialId,
'authenticatorData': event.authenticatorData,
'clientDataJSON': event.clientDataJSON,
'signature': event.signature,
},
fromJson: (data) => data,
);
final token = response['token'];
final user = response['user'];
sessionBloc.add(SessionStarted(token));
emit(
AuthAuthenticated(
token: token,
userId: user['id'],
username: user['username'],
email: user['email'],
),
);
} catch (e) {
emit(AuthFailure(e.toString()));
}
}
Future<void> _onLogoutRequested(
LogoutRequested event, LogoutRequested event,
Emitter<AuthState> emit, Emitter<AuthState> emit,
) async { ) async {
emit(AuthLoading()); emit(AuthLoading(message: 'Logging out...'));
// Call logout API
await Future.delayed(const Duration(milliseconds: 500)); await Future.delayed(const Duration(milliseconds: 500));
sessionBloc.add(SessionExpired());
emit(AuthUnauthenticated()); emit(AuthUnauthenticated());
} }
void _onCheckAuthRequested( Future<void> _onPasswordLoginRequested(
PasswordLoginRequested event,
Emitter<AuthState> emit,
) async {
emit(AuthLoading(message: 'Authenticating...'));
try {
final response = await apiClient.post(
'/auth/password-login',
data: {'username': event.username, 'password': event.password},
fromJson: (data) => data,
);
final token = response['token'];
final user = response['user'];
sessionBloc.add(SessionStarted(token));
emit(
AuthAuthenticated(
token: token,
userId: user['id'],
username: user['username'],
email: user['email'],
),
);
} catch (e) {
emit(AuthFailure('Login failed: ${e.toString()}'));
}
}
Future<void> _onCheckAuthRequested(
CheckAuthRequested event, CheckAuthRequested event,
Emitter<AuthState> emit, Emitter<AuthState> emit,
) async { ) async {
// Check if token is valid // Check if token is valid in SessionBloc
// For now, assume unauthenticated
emit(AuthUnauthenticated()); emit(AuthUnauthenticated());
} }
} }

View File

@@ -3,12 +3,135 @@ import 'package:equatable/equatable.dart';
abstract class AuthEvent extends Equatable { abstract class AuthEvent extends Equatable {
const AuthEvent(); const AuthEvent();
@override
List<Object?> get props => [];
}
// Signup events
class SignupStarted extends AuthEvent {
final String username;
final String email;
final String displayName;
final String? password;
const SignupStarted({
required this.username,
required this.email,
required this.displayName,
this.password,
});
@override
List<Object?> get props => [username, email, displayName, password];
}
class RegistrationChallengeRequested extends AuthEvent {
final String userId;
const RegistrationChallengeRequested({required this.userId});
@override
List<Object> get props => [userId];
}
class RegistrationResponseSubmitted extends AuthEvent {
final String userId;
final String challenge;
final String credentialId;
final String publicKey;
final String clientDataJSON;
final String attestationObject;
const RegistrationResponseSubmitted({
required this.userId,
required this.challenge,
required this.credentialId,
required this.publicKey,
required this.clientDataJSON,
required this.attestationObject,
});
@override
List<Object> get props => [
userId,
challenge,
credentialId,
publicKey,
clientDataJSON,
attestationObject,
];
}
// Login events
class LoginRequested extends AuthEvent {
final String username;
const LoginRequested({required this.username});
@override
List<Object> get props => [username];
}
class AuthenticationChallengeRequested extends AuthEvent {
final String username;
const AuthenticationChallengeRequested({required this.username});
@override
List<Object> get props => [username];
}
class AuthenticationResponseSubmitted extends AuthEvent {
final String username;
final String challenge;
final String credentialId;
final String authenticatorData;
final String clientDataJSON;
final String signature;
const AuthenticationResponseSubmitted({
required this.username,
required this.challenge,
required this.credentialId,
required this.authenticatorData,
required this.clientDataJSON,
required this.signature,
});
@override
List<Object> get props => [
username,
challenge,
credentialId,
authenticatorData,
clientDataJSON,
signature,
];
}
class LogoutRequested extends AuthEvent {
const LogoutRequested();
@override @override
List<Object> get props => []; List<Object> get props => [];
} }
class LoginRequested extends AuthEvent {} class CheckAuthRequested extends AuthEvent {
const CheckAuthRequested();
class LogoutRequested extends AuthEvent {} @override
List<Object> get props => [];
}
class CheckAuthRequested extends AuthEvent {} class PasswordLoginRequested extends AuthEvent {
final String username;
final String password;
const PasswordLoginRequested({
required this.username,
required this.password,
});
@override
List<Object> get props => [username, password];
}

View File

@@ -4,25 +4,82 @@ abstract class AuthState extends Equatable {
const AuthState(); const AuthState();
@override @override
List<Object> get props => []; List<Object?> get props => [];
} }
class AuthInitial extends AuthState {} class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {} class AuthLoading extends AuthState {
final String? message;
const AuthLoading({this.message});
@override
List<Object?> get props => [message];
}
class SignupInProgress extends AuthState {
final String username;
final String email;
final String displayName;
const SignupInProgress({
required this.username,
required this.email,
required this.displayName,
});
@override
List<Object> get props => [username, email, displayName];
}
class RegistrationChallengeReceived extends AuthState {
final String userId;
final String challenge;
final String rpName;
final String rpId;
const RegistrationChallengeReceived({
required this.userId,
required this.challenge,
required this.rpName,
required this.rpId,
});
@override
List<Object> get props => [userId, challenge, rpName, rpId];
}
class AuthenticationChallengeReceived extends AuthState {
final String challenge;
final List<String> credentialIds;
const AuthenticationChallengeReceived({
required this.challenge,
required this.credentialIds,
});
@override
List<Object> get props => [challenge, credentialIds];
}
class AuthAuthenticated extends AuthState { class AuthAuthenticated extends AuthState {
final String token; final String token;
final String userId; final String userId;
final String username;
final String email;
const AuthAuthenticated({required this.token, required this.userId}); const AuthAuthenticated({
required this.token,
required this.userId,
required this.username,
required this.email,
});
@override @override
List<Object> get props => [token, userId]; List<Object> get props => [token, userId, username, email];
} }
class AuthUnauthenticated extends AuthState {}
class AuthFailure extends AuthState { class AuthFailure extends AuthState {
final String error; final String error;
@@ -31,3 +88,10 @@ class AuthFailure extends AuthState {
@override @override
List<Object> get props => [error]; List<Object> get props => [error];
} }
class AuthUnauthenticated extends AuthState {
const AuthUnauthenticated();
@override
List<Object> get props => [];
}

View File

@@ -3,18 +3,12 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'blocs/auth/auth_bloc.dart'; import 'blocs/auth/auth_bloc.dart';
import 'blocs/session/session_bloc.dart'; import 'blocs/session/session_bloc.dart';
import 'blocs/organization/organization_bloc.dart';
import 'blocs/permission/permission_bloc.dart';
import 'blocs/file_browser/file_browser_bloc.dart';
import 'blocs/upload/upload_bloc.dart';
import 'blocs/activity/activity_bloc.dart'; import 'blocs/activity/activity_bloc.dart';
import 'repositories/mock_file_repository.dart';
import 'services/file_service.dart';
import 'services/api_client.dart'; import 'services/api_client.dart';
import 'services/org_api.dart';
import 'services/activity_api.dart'; import 'services/activity_api.dart';
import 'pages/home_page.dart'; import 'pages/home_page.dart';
import 'pages/login_form.dart'; import 'pages/login_form.dart';
import 'pages/signup_form.dart';
import 'pages/file_explorer.dart'; import 'pages/file_explorer.dart';
import 'pages/document_viewer.dart'; import 'pages/document_viewer.dart';
import 'pages/editor_page.dart'; import 'pages/editor_page.dart';
@@ -24,6 +18,7 @@ final GoRouter _router = GoRouter(
routes: [ routes: [
GoRoute(path: '/', builder: (context, state) => const HomePage()), GoRoute(path: '/', builder: (context, state) => const HomePage()),
GoRoute(path: '/login', builder: (context, state) => const LoginForm()), GoRoute(path: '/login', builder: (context, state) => const LoginForm()),
GoRoute(path: '/signup', builder: (context, state) => const SignupForm()),
GoRoute( GoRoute(
path: '/viewer/:orgId/:fileId', path: '/viewer/:orgId/:fileId',
@@ -58,25 +53,11 @@ class MainApp extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiBlocProvider( return MultiBlocProvider(
providers: [ providers: [
BlocProvider<AuthBloc>(create: (_) => AuthBloc()),
BlocProvider<SessionBloc>(create: (_) => SessionBloc()), BlocProvider<SessionBloc>(create: (_) => SessionBloc()),
BlocProvider<PermissionBloc>(create: (_) => PermissionBloc()), BlocProvider<AuthBloc>(
BlocProvider<FileBrowserBloc>( create: (context) => AuthBloc(
create: (context) => FileBrowserBloc( apiClient: ApiClient(context.read<SessionBloc>()),
FileService(ApiClient(context.read<SessionBloc>())), sessionBloc: context.read<SessionBloc>(),
),
),
BlocProvider<UploadBloc>(
create: (_) =>
UploadBloc(MockFileRepository()), // keep mock for upload
),
BlocProvider<OrganizationBloc>(
lazy: true,
create: (context) => OrganizationBloc(
context.read<PermissionBloc>(),
context.read<FileBrowserBloc>(),
context.read<UploadBloc>(),
OrgApi(ApiClient(context.read<SessionBloc>())),
), ),
), ),
BlocProvider<ActivityBloc>( BlocProvider<ActivityBloc>(

View File

@@ -0,0 +1,63 @@
import 'package:equatable/equatable.dart';
class Credential extends Equatable {
final String id;
final String userId;
final String credentialId;
final String publicKey;
final int signCount;
final DateTime createdAt;
final DateTime? lastUsedAt;
final List<String>? transports;
const Credential({
required this.id,
required this.userId,
required this.credentialId,
required this.publicKey,
this.signCount = 0,
required this.createdAt,
this.lastUsedAt,
this.transports,
});
@override
List<Object?> get props => [
id,
userId,
credentialId,
publicKey,
signCount,
createdAt,
lastUsedAt,
transports,
];
factory Credential.fromJson(Map<String, dynamic> json) {
return Credential(
id: json['id'] as String,
userId: json['userId'] as String,
credentialId: json['credentialId'] as String,
publicKey: json['publicKey'] as String,
signCount: json['signCount'] as int? ?? 0,
createdAt: DateTime.parse(json['createdAt'] as String),
lastUsedAt: json['lastUsedAt'] != null
? DateTime.parse(json['lastUsedAt'] as String)
: null,
transports: (json['transports'] as List<dynamic>?)?.cast<String>(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'userId': userId,
'credentialId': credentialId,
'publicKey': publicKey,
'signCount': signCount,
'createdAt': createdAt.toIso8601String(),
'lastUsedAt': lastUsedAt?.toIso8601String(),
'transports': transports,
};
}
}

View File

@@ -1,15 +1,73 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
class User extends Equatable { class User extends Equatable {
final String id;
final String username;
final String email; final String email;
final String? name; final String? displayName;
final DateTime createdAt;
final DateTime? lastLoginAt;
const User({required this.email, this.name}); const User({
required this.id,
required this.username,
required this.email,
this.displayName,
required this.createdAt,
this.lastLoginAt,
});
@override @override
List<Object?> get props => [email, name]; List<Object?> get props => [
id,
username,
email,
displayName,
createdAt,
lastLoginAt,
];
User copyWith({String? email, String? name}) { User copyWith({
return User(email: email ?? this.email, name: name ?? this.name); String? id,
String? username,
String? email,
String? displayName,
DateTime? createdAt,
DateTime? lastLoginAt,
}) {
return User(
id: id ?? this.id,
username: username ?? this.username,
email: email ?? this.email,
displayName: displayName ?? this.displayName,
createdAt: createdAt ?? this.createdAt,
lastLoginAt: lastLoginAt ?? this.lastLoginAt,
);
}
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'] as String,
username: json['username'] as String,
email: json['email'] as String,
displayName: json['displayName'] as String?,
createdAt: DateTime.parse(
json['createdAt'] as String? ?? DateTime.now().toIso8601String(),
),
lastLoginAt: json['lastLoginAt'] != null
? DateTime.parse(json['lastLoginAt'] as String)
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'username': username,
'email': email,
'displayName': displayName,
'createdAt': createdAt.toIso8601String(),
'lastLoginAt': lastLoginAt?.toIso8601String(),
};
} }
} }

View File

@@ -10,7 +10,7 @@ import '../blocs/file_browser/file_browser_bloc.dart';
import '../blocs/file_browser/file_browser_event.dart'; import '../blocs/file_browser/file_browser_event.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import '../theme/modern_glass_button.dart'; import '../theme/modern_glass_button.dart';
import 'login_form.dart'; import 'login_form.dart' show LoginForm;
import 'file_explorer.dart'; import 'file_explorer.dart';
class HomePage extends StatefulWidget { class HomePage extends StatefulWidget {

View File

@@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'dart:math';
import '../blocs/auth/auth_bloc.dart'; import '../blocs/auth/auth_bloc.dart';
import '../blocs/auth/auth_event.dart'; import '../blocs/auth/auth_event.dart';
import '../blocs/auth/auth_state.dart'; import '../blocs/auth/auth_state.dart';
@@ -16,8 +18,55 @@ class LoginForm extends StatefulWidget {
} }
class _LoginFormState extends State<LoginForm> { class _LoginFormState extends State<LoginForm> {
final _emailController = TextEditingController(); final _usernameController = TextEditingController();
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
bool _usePasskey = true;
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
String _generateRandomHex(int bytes) {
final random = Random();
final values = List<int>.generate(bytes, (i) => random.nextInt(256));
return values.map((v) => v.toRadixString(16).padLeft(2, '0')).join();
}
Future<void> _handleAuthentication(
BuildContext context,
AuthenticationChallengeReceived state,
) async {
try {
// Simulate WebAuthn authentication by generating fake assertion data
// In a real implementation, this would call native WebAuthn APIs
final credentialId = state.credentialIds.isNotEmpty
? state.credentialIds.first
: _generateRandomHex(64);
if (context.mounted) {
context.read<AuthBloc>().add(
AuthenticationResponseSubmitted(
username: _usernameController.text,
challenge: state.challenge,
credentialId: credentialId,
authenticatorData: _generateRandomHex(37),
clientDataJSON:
'{"type":"webauthn.get","challenge":"${state.challenge}","origin":"https://b0esche.cloud"}',
signature: _generateRandomHex(128),
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Authentication failed: $e')));
}
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -27,62 +76,69 @@ class _LoginFormState extends State<LoginForm> {
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
).showSnackBar(SnackBar(content: Text(state.error))); ).showSnackBar(SnackBar(content: Text(state.error)));
} else if (state is AuthenticationChallengeReceived) {
_handleAuthentication(context, state);
} else if (state is AuthAuthenticated) { } else if (state is AuthAuthenticated) {
// Start session // Start session
context.read<SessionBloc>().add(SessionStarted(state.token)); context.read<SessionBloc>().add(SessionStarted(state.token));
context.go('/');
} }
}, },
child: Center( child: Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Column( child: SingleChildScrollView(
mainAxisSize: MainAxisSize.min, child: Column(
crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.min,
children: [ crossAxisAlignment: CrossAxisAlignment.center,
const Text( children: [
'sign in', const Text(
style: TextStyle(fontSize: 24, color: AppTheme.primaryText), 'sign in',
), style: TextStyle(fontSize: 24, color: AppTheme.primaryText),
const SizedBox(height: 16), ),
Container( const SizedBox(height: 24),
decoration: BoxDecoration( Container(
color: AppTheme.primaryBackground.withValues(alpha: 0.5), decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16), color: AppTheme.primaryBackground.withValues(alpha: 0.5),
border: Border.all( borderRadius: BorderRadius.circular(16),
color: AppTheme.accentColor.withValues(alpha: 0.3), border: Border.all(
color: AppTheme.accentColor.withValues(alpha: 0.3),
),
),
child: TextField(
controller: _usernameController,
textInputAction: TextInputAction.done,
keyboardType: TextInputType.text,
cursorColor: AppTheme.accentColor,
decoration: InputDecoration(
hintText: 'username',
hintStyle: TextStyle(color: AppTheme.secondaryText),
contentPadding: const EdgeInsets.all(12),
border: InputBorder.none,
prefixIcon: Icon(
Icons.person_outline,
color: AppTheme.primaryText,
size: 20,
),
),
style: const TextStyle(color: AppTheme.primaryText),
), ),
), ),
child: Column( const SizedBox(height: 16),
children: [ if (!_usePasskey)
TextField( Container(
controller: _emailController, decoration: BoxDecoration(
textInputAction: TextInputAction.next, color: AppTheme.primaryBackground.withValues(alpha: 0.5),
keyboardType: TextInputType.emailAddress, borderRadius: BorderRadius.circular(16),
cursorColor: AppTheme.accentColor, border: Border.all(
decoration: InputDecoration( color: AppTheme.accentColor.withValues(alpha: 0.3),
hintText: 'email',
hintStyle: TextStyle(color: AppTheme.secondaryText),
contentPadding: const EdgeInsets.all(12),
border: InputBorder.none,
prefixIcon: Icon(
Icons.email_outlined,
color: AppTheme.primaryText,
size: 20,
),
), ),
style: const TextStyle(color: AppTheme.primaryText),
), ),
Divider( child: TextField(
color: AppTheme.accentColor.withValues(alpha: 0.2),
height: 1,
thickness: 1,
),
TextField(
controller: _passwordController, controller: _passwordController,
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
keyboardType: TextInputType.visiblePassword, keyboardType: TextInputType.visiblePassword,
obscureText: true, obscureText: true,
obscuringCharacter: '',
cursorColor: AppTheme.accentColor, cursorColor: AppTheme.accentColor,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'password', hintText: 'password',
@@ -90,37 +146,96 @@ class _LoginFormState extends State<LoginForm> {
contentPadding: const EdgeInsets.all(12), contentPadding: const EdgeInsets.all(12),
border: InputBorder.none, border: InputBorder.none,
prefixIcon: Icon( prefixIcon: Icon(
Icons.lock_outline_rounded, Icons.lock_outline,
color: AppTheme.primaryText, color: AppTheme.primaryText,
size: 20, size: 20,
), ),
), ),
style: const TextStyle(color: AppTheme.primaryText), style: const TextStyle(color: AppTheme.primaryText),
), ),
),
if (!_usePasskey) const SizedBox(height: 16),
SizedBox(
width: 150,
child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
return ModernGlassButton(
isLoading: state is AuthLoading,
onPressed: () {
if (_usernameController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Username is required'),
),
);
return;
}
if (!_usePasskey && _passwordController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Password is required'),
),
);
return;
}
if (_usePasskey) {
context.read<AuthBloc>().add(
LoginRequested(username: _usernameController.text),
);
} else {
context.read<AuthBloc>().add(
PasswordLoginRequested(
username: _usernameController.text,
password: _passwordController.text,
),
);
}
},
child: const Text('sign in'),
);
},
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
GestureDetector(
onTap: () => setState(() => _usePasskey = !_usePasskey),
child: Text(
_usePasskey ? 'use password' : 'use passkey',
style: TextStyle(
color: AppTheme.accentColor,
decoration: TextDecoration.underline,
fontSize: 12,
),
),
),
], ],
), ),
), const SizedBox(height: 16),
const SizedBox(height: 16), Row(
SizedBox( mainAxisAlignment: MainAxisAlignment.center,
width: 150, children: [
child: BlocBuilder<AuthBloc, AuthState>( Text(
builder: (context, state) { 'don\'t have an account?',
return ModernGlassButton( style: TextStyle(color: AppTheme.secondaryText),
isLoading: state is AuthLoading, ),
onPressed: () { const SizedBox(width: 8),
context.read<AuthBloc>().add( GestureDetector(
LoginRequested( onTap: () => context.go('/signup'),
// _emailController.text, child: Text(
// _passwordController.text, 'create one',
), style: TextStyle(
); color: AppTheme.accentColor,
}, decoration: TextDecoration.underline,
child: const Text('sign in'), ),
); ),
}, ),
],
), ),
), ],
], ),
), ),
), ),
), ),

View File

@@ -0,0 +1,236 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'dart:math';
import '../blocs/auth/auth_bloc.dart';
import '../blocs/auth/auth_event.dart';
import '../blocs/auth/auth_state.dart';
import '../theme/app_theme.dart';
import '../theme/modern_glass_button.dart';
class SignupForm extends StatefulWidget {
const SignupForm({super.key});
@override
State<SignupForm> createState() => _SignupFormState();
}
class _SignupFormState extends State<SignupForm> {
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
final _displayNameController = TextEditingController();
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
_displayNameController.dispose();
super.dispose();
}
String _generateRandomHex(int bytes) {
final random = Random();
final values = List<int>.generate(bytes, (i) => random.nextInt(256));
return values.map((v) => v.toRadixString(16).padLeft(2, '0')).join();
}
Future<void> _handleRegistration(
BuildContext context,
RegistrationChallengeReceived state,
) async {
try {
// Simulate WebAuthn registration by generating fake credential data
// In a real implementation, this would call native WebAuthn APIs
final credentialId = _generateRandomHex(64);
final publicKey = _generateRandomHex(91); // EC2 public key size
if (context.mounted) {
context.read<AuthBloc>().add(
RegistrationResponseSubmitted(
userId: state.userId,
challenge: state.challenge,
credentialId: credentialId,
publicKey: publicKey,
clientDataJSON:
'{"type":"webauthn.create","challenge":"${state.challenge}","origin":"https://b0esche.cloud"}',
attestationObject: _generateRandomHex(128),
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Registration failed: $e')));
}
}
}
@override
Widget build(BuildContext context) {
return BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthFailure) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(state.error)));
} else if (state is RegistrationChallengeReceived) {
_handleRegistration(context, state);
} else if (state is AuthAuthenticated) {
context.go('/');
}
},
child: Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Text(
'create account',
style: TextStyle(fontSize: 24, color: AppTheme.primaryText),
),
const SizedBox(height: 24),
Container(
decoration: BoxDecoration(
color: AppTheme.primaryBackground.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppTheme.accentColor.withValues(alpha: 0.3),
),
),
child: Column(
children: [
TextField(
controller: _usernameController,
textInputAction: TextInputAction.next,
keyboardType: TextInputType.text,
cursorColor: AppTheme.accentColor,
decoration: InputDecoration(
hintText: 'username',
hintStyle: TextStyle(color: AppTheme.secondaryText),
contentPadding: const EdgeInsets.all(12),
border: InputBorder.none,
prefixIcon: Icon(
Icons.person_outline,
color: AppTheme.primaryText,
size: 20,
),
),
style: const TextStyle(color: AppTheme.primaryText),
),
Divider(
color: AppTheme.accentColor.withValues(alpha: 0.2),
height: 1,
thickness: 1,
),
TextField(
controller: _passwordController,
textInputAction: TextInputAction.next,
keyboardType: TextInputType.visiblePassword,
obscureText: true,
cursorColor: AppTheme.accentColor,
decoration: InputDecoration(
hintText: 'password',
hintStyle: TextStyle(color: AppTheme.secondaryText),
contentPadding: const EdgeInsets.all(12),
border: InputBorder.none,
prefixIcon: Icon(
Icons.lock_outline,
color: AppTheme.primaryText,
size: 20,
),
),
style: const TextStyle(color: AppTheme.primaryText),
),
Divider(
color: AppTheme.accentColor.withValues(alpha: 0.2),
height: 1,
thickness: 1,
),
TextField(
controller: _displayNameController,
textInputAction: TextInputAction.done,
keyboardType: TextInputType.text,
cursorColor: AppTheme.accentColor,
decoration: InputDecoration(
hintText: 'display name (optional)',
hintStyle: TextStyle(color: AppTheme.secondaryText),
contentPadding: const EdgeInsets.all(12),
border: InputBorder.none,
prefixIcon: Icon(
Icons.badge_outlined,
color: AppTheme.primaryText,
size: 20,
),
),
style: const TextStyle(color: AppTheme.primaryText),
),
],
),
),
const SizedBox(height: 16),
SizedBox(
width: 150,
child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
return ModernGlassButton(
isLoading: state is AuthLoading,
onPressed: () {
if (_usernameController.text.isEmpty ||
_passwordController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Username and password are required',
),
),
);
return;
}
context.read<AuthBloc>().add(
SignupStarted(
username: _usernameController.text,
email: _usernameController
.text, // Use username as email for now
displayName: _displayNameController.text,
password: _passwordController.text,
),
);
},
child: const Text('create'),
);
},
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'already have an account?',
style: TextStyle(color: AppTheme.secondaryText),
),
const SizedBox(width: 8),
GestureDetector(
onTap: () => context.go('/login'),
child: Text(
'sign in',
style: TextStyle(
color: AppTheme.accentColor,
decoration: TextDecoration.underline,
),
),
),
],
),
],
),
),
),
),
);
}
}

View File

@@ -6,7 +6,12 @@ class MockAuthRepository implements AuthRepository {
Future<User> login(String email, String password) async { Future<User> login(String email, String password) async {
await Future.delayed(const Duration(seconds: 1)); await Future.delayed(const Duration(seconds: 1));
if (email.isNotEmpty && password.isNotEmpty) { if (email.isNotEmpty && password.isNotEmpty) {
return User(email: email); return User(
id: 'mock-user-id',
username: 'mockuser',
email: email,
createdAt: DateTime.now(),
);
} else { } else {
throw Exception('Invalid credentials'); throw Exception('Invalid credentials');
} }

View File

@@ -2,7 +2,6 @@ import '../models/file_item.dart';
import '../models/viewer_session.dart'; import '../models/viewer_session.dart';
import '../models/editor_session.dart'; import '../models/editor_session.dart';
import '../models/annotation.dart'; import '../models/annotation.dart';
import '../repositories/file_repository.dart';
import 'api_client.dart'; import 'api_client.dart';
class FileService { class FileService {

View File

@@ -37,8 +37,7 @@ dependencies:
logger: ^2.0.2 logger: ^2.0.2
# Image Handling # Image Handling
cached_network_image: cached_network_image: ^3.3.0
^3.3.0
# SVG Support # SVG Support
flutter_svg: ^2.0.9 flutter_svg: ^2.0.9

Binary file not shown.

View File

@@ -25,11 +25,7 @@ func main() {
jwtManager := jwt.NewManager(cfg.JWTSecret) jwtManager := jwt.NewManager(cfg.JWTSecret)
authService, err := auth.NewService(cfg, db) authService := auth.NewService(db)
if err != nil {
fmt.Fprintf(os.Stderr, "Auth service error: %v\n", err)
os.Exit(1)
}
auditLogger := audit.NewLogger(db) auditLogger := audit.NewLogger(db)

View File

@@ -14,13 +14,13 @@ import (
"golang.org/x/oauth2" "golang.org/x/oauth2"
) )
type Service struct { type OIDCService struct {
provider *oidc.Provider provider *oidc.Provider
oauth2Config oauth2.Config oauth2Config oauth2.Config
db *database.DB // Assume we have a DB wrapper db *database.DB // Assume we have a DB wrapper
} }
func NewService(cfg *config.Config, db *database.DB) (*Service, error) { func NewOIDCService(cfg *config.Config, db *database.DB) (*OIDCService, error) {
ctx := context.Background() ctx := context.Background()
provider, err := oidc.NewProvider(ctx, cfg.OIDCIssuerURL) provider, err := oidc.NewProvider(ctx, cfg.OIDCIssuerURL)
@@ -36,18 +36,18 @@ func NewService(cfg *config.Config, db *database.DB) (*Service, error) {
Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
} }
return &Service{ return &OIDCService{
provider: provider, provider: provider,
oauth2Config: oauth2Config, oauth2Config: oauth2Config,
db: db, db: db,
}, nil }, nil
} }
func (s *Service) LoginURL(state string) string { func (s *OIDCService) LoginURL(state string) string {
return s.oauth2Config.AuthCodeURL(state) return s.oauth2Config.AuthCodeURL(state)
} }
func (s *Service) HandleCallback(ctx context.Context, code, state string) (*database.User, *database.Session, error) { func (s *OIDCService) HandleCallback(ctx context.Context, code, state string) (*database.User, *database.Session, error) {
oauth2Token, err := s.oauth2Config.Exchange(ctx, code) oauth2Token, err := s.oauth2Config.Exchange(ctx, code)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("failed to exchange code: %w", err) return nil, nil, fmt.Errorf("failed to exchange code: %w", err)

View File

@@ -0,0 +1,323 @@
package auth
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"github.com/google/uuid"
"go.b0esche.cloud/backend/internal/database"
"golang.org/x/crypto/bcrypt"
)
const (
ChallengeLength = 32
RPID = "b0esche.cloud"
RPName = "b0esche Cloud"
Origin = "https://b0esche.cloud"
)
type Service struct {
db *database.DB
}
func NewService(db *database.DB) *Service {
return &Service{db: db}
}
// StartRegistrationChallenge creates a challenge for passkey registration
func (s *Service) StartRegistrationChallenge(ctx context.Context, userID uuid.UUID) (string, error) {
challenge := make([]byte, ChallengeLength)
if _, err := rand.Read(challenge); err != nil {
return "", fmt.Errorf("failed to generate challenge: %w", err)
}
challengeStr := base64.StdEncoding.EncodeToString(challenge)
// Store challenge in database
if err := s.db.CreateAuthChallenge(ctx, userID, challenge, "registration"); err != nil {
return "", fmt.Errorf("failed to store challenge: %w", err)
}
return challengeStr, nil
}
// StartAuthenticationChallenge creates a challenge for passkey authentication
func (s *Service) StartAuthenticationChallenge(ctx context.Context, username string) (string, []string, error) {
// Get user by username
user, err := s.db.GetUserByUsername(ctx, username)
if err != nil {
return "", nil, fmt.Errorf("user not found: %w", err)
}
challenge := make([]byte, ChallengeLength)
if _, err := rand.Read(challenge); err != nil {
return "", nil, fmt.Errorf("failed to generate challenge: %w", err)
}
challengeStr := base64.StdEncoding.EncodeToString(challenge)
// Store challenge in database
if err := s.db.CreateAuthChallenge(ctx, user.ID, challenge, "authentication"); err != nil {
return "", nil, fmt.Errorf("failed to store challenge: %w", err)
}
// Get user's credentials
credentials, err := s.db.GetUserCredentials(ctx, user.ID)
if err != nil {
return "", nil, fmt.Errorf("failed to get credentials: %w", err)
}
// Return credential IDs (base64 encoded for transport)
var credentialIDs []string
for _, cred := range credentials {
credentialIDs = append(credentialIDs, base64.StdEncoding.EncodeToString(cred.CredentialID))
}
return challengeStr, credentialIDs, nil
}
// VerifyRegistrationResponse verifies the attestation response from the client
func (s *Service) VerifyRegistrationResponse(
ctx context.Context,
userID uuid.UUID,
challengeB64 string,
credentialIDBase64 string,
publicKeyBase64 string,
clientDataJSON string,
attestationObjectBase64 string,
) (*database.Credential, error) {
// Decode inputs
challenge, err := base64.StdEncoding.DecodeString(challengeB64)
if err != nil {
return nil, fmt.Errorf("invalid challenge encoding: %w", err)
}
credentialID, err := base64.StdEncoding.DecodeString(credentialIDBase64)
if err != nil {
return nil, fmt.Errorf("invalid credential ID encoding: %w", err)
}
publicKeyBytes, err := base64.StdEncoding.DecodeString(publicKeyBase64)
if err != nil {
return nil, fmt.Errorf("invalid public key encoding: %w", err)
}
_, err = base64.StdEncoding.DecodeString(attestationObjectBase64)
if err != nil {
return nil, fmt.Errorf("invalid attestation object encoding: %w", err)
}
// Verify challenge exists and belongs to this user
if err := s.verifyChallenge(ctx, userID, challenge, "registration"); err != nil {
return nil, fmt.Errorf("challenge verification failed: %w", err)
}
// In production, you would parse and verify the attestation object here
// For now, we'll just verify the client data matches
var clientData struct {
Type string `json:"type"`
Challenge string `json:"challenge"`
Origin string `json:"origin"`
}
if err := json.Unmarshal([]byte(clientDataJSON), &clientData); err != nil {
return nil, fmt.Errorf("invalid client data JSON: %w", err)
}
// Verify challenge in client data
clientDataChallenge, err := base64.StdEncoding.DecodeString(clientData.Challenge)
if err != nil {
return nil, fmt.Errorf("invalid challenge in client data: %w", err)
}
// Verify challenge matches (we skip the hash verification since it's not needed for API validation)
// clientDataHash := sha256.Sum256([]byte(clientDataJSON))
// Verify challenge matches
if !byteArraysEqual(clientDataChallenge, challenge) {
return nil, fmt.Errorf("challenge mismatch")
}
// Verify origin
if clientData.Origin != Origin {
return nil, fmt.Errorf("origin mismatch: expected %s, got %s", Origin, clientData.Origin)
}
// Verify type
if clientData.Type != "webauthn.create" {
return nil, fmt.Errorf("invalid client data type: %s", clientData.Type)
}
// Store credential in database
credential := &database.Credential{
ID: base64.StdEncoding.EncodeToString(credentialID),
UserID: userID,
CredentialPublicKey: publicKeyBytes,
CredentialID: credentialID,
SignCount: 0,
}
if err := s.db.CreateCredential(ctx, credential); err != nil {
return nil, fmt.Errorf("failed to store credential: %w", err)
}
// Mark challenge as used
if err := s.db.MarkChallengeUsed(ctx, challenge); err != nil {
return nil, fmt.Errorf("failed to mark challenge as used: %w", err)
}
return credential, nil
}
// VerifyAuthenticationResponse verifies the assertion response from the client
func (s *Service) VerifyAuthenticationResponse(
ctx context.Context,
username string,
challengeB64 string,
credentialIDBase64 string,
authenticatorData string,
clientDataJSON string,
signatureBase64 string,
) (*database.User, error) {
// Get user by username
user, err := s.db.GetUserByUsername(ctx, username)
if err != nil {
return nil, fmt.Errorf("user not found: %w", err)
}
// Decode challenge
challenge, err := base64.StdEncoding.DecodeString(challengeB64)
if err != nil {
return nil, fmt.Errorf("invalid challenge encoding: %w", err)
}
// Verify challenge
if err := s.verifyChallenge(ctx, user.ID, challenge, "authentication"); err != nil {
return nil, fmt.Errorf("challenge verification failed: %w", err)
}
// Decode credential ID
credentialID, err := base64.StdEncoding.DecodeString(credentialIDBase64)
if err != nil {
return nil, fmt.Errorf("invalid credential ID encoding: %w", err)
}
// Get credential from database
credential, err := s.db.GetCredentialByID(ctx, credentialID)
if err != nil {
return nil, fmt.Errorf("credential not found: %w", err)
}
// Verify credential belongs to user
if credential.UserID != user.ID {
return nil, fmt.Errorf("credential does not belong to user")
}
// Parse and verify client data
var clientData struct {
Type string `json:"type"`
Challenge string `json:"challenge"`
Origin string `json:"origin"`
}
if err := json.Unmarshal([]byte(clientDataJSON), &clientData); err != nil {
return nil, fmt.Errorf("invalid client data JSON: %w", err)
}
// Verify challenge matches
clientDataChallenge, err := base64.StdEncoding.DecodeString(clientData.Challenge)
if err != nil {
return nil, fmt.Errorf("invalid challenge in client data: %w", err)
}
if !byteArraysEqual(clientDataChallenge, challenge) {
return nil, fmt.Errorf("challenge mismatch")
}
// Verify origin
if clientData.Origin != Origin {
return nil, fmt.Errorf("origin mismatch: expected %s, got %s", Origin, clientData.Origin)
}
// Verify type
if clientData.Type != "webauthn.get" {
return nil, fmt.Errorf("invalid client data type: %s", clientData.Type)
}
// In production, you would verify the signature here using the public key
// For now, we'll assume the signature is valid if we got this far
// Mark challenge as used
if err := s.db.MarkChallengeUsed(ctx, challenge); err != nil {
return nil, fmt.Errorf("failed to mark challenge as used: %w", err)
}
// Update credential last used time
if err := s.db.UpdateCredentialLastUsed(ctx, credential.ID); err != nil {
return nil, fmt.Errorf("failed to update credential last used: %w", err)
}
// Update user last login
if err := s.db.UpdateUserLastLogin(ctx, user.ID); err != nil {
return nil, fmt.Errorf("failed to update user last login: %w", err)
}
return user, nil
}
func (s *Service) verifyChallenge(ctx context.Context, userID uuid.UUID, challenge []byte, challengeType string) error {
return s.db.VerifyAuthChallenge(ctx, userID, challenge, challengeType)
}
func byteArraysEqual(a, b []byte) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// HashPassword hashes a password using bcrypt
func (s *Service) HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", fmt.Errorf("failed to hash password: %w", err)
}
return string(hash), nil
}
// VerifyPassword checks if a password matches its hash
func (s *Service) VerifyPassword(passwordHash string, password string) bool {
err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password))
return err == nil
}
// VerifyPasswordLogin verifies username and password credentials
func (s *Service) VerifyPasswordLogin(ctx context.Context, username, password string) (*database.User, error) {
user, err := s.db.GetUserByUsername(ctx, username)
if err != nil {
return nil, fmt.Errorf("user not found: %w", err)
}
if user.PasswordHash == nil || *user.PasswordHash == "" {
return nil, fmt.Errorf("user does not have a password set")
}
if !s.VerifyPassword(*user.PasswordHash, password) {
return nil, fmt.Errorf("invalid password")
}
// Update last login
if err := s.db.UpdateUserLastLogin(ctx, user.ID); err != nil {
return nil, fmt.Errorf("failed to update user last login: %w", err)
}
return user, nil
}

View File

@@ -17,11 +17,34 @@ func New(db *sql.DB) *DB {
} }
type User struct { type User struct {
ID uuid.UUID ID uuid.UUID
Email string Email string
DisplayName string Username string
CreatedAt time.Time DisplayName string
LastLoginAt *time.Time PasswordHash *string
CreatedAt time.Time
LastLoginAt *time.Time
}
type Credential struct {
ID string
UserID uuid.UUID
CredentialPublicKey []byte
CredentialID []byte
SignCount int64
CreatedAt time.Time
LastUsedAt *time.Time
Transports []string
}
type AuthChallenge struct {
ID uuid.UUID
UserID uuid.UUID
Challenge []byte
ChallengeType string
CreatedAt time.Time
ExpiresAt time.Time
UsedAt *time.Time
} }
type Session struct { type Session struct {
@@ -218,3 +241,153 @@ func (db *DB) UpdateMemberRole(ctx context.Context, orgID, userID uuid.UUID, rol
`, role, orgID, userID) `, role, orgID, userID)
return err return err
} }
// Passkey-related methods
func (db *DB) CreateUser(ctx context.Context, username, email, displayName string, passwordHash *string) (*User, error) {
var user User
err := db.QueryRowContext(ctx, `
INSERT INTO users (id, username, email, display_name, password_hash)
VALUES (gen_random_uuid(), $1, $2, $3, $4)
RETURNING id, username, email, display_name, password_hash, created_at, last_login_at
`, username, email, displayName, passwordHash).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.PasswordHash, &user.CreatedAt, &user.LastLoginAt)
if err != nil {
return nil, err
}
return &user, nil
}
func (db *DB) GetUserByUsername(ctx context.Context, username string) (*User, error) {
var user User
err := db.QueryRowContext(ctx, `
SELECT id, username, email, display_name, password_hash, created_at, last_login_at
FROM users
WHERE username = $1
`, username).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.PasswordHash, &user.CreatedAt, &user.LastLoginAt)
if err != nil {
return nil, err
}
return &user, nil
}
func (db *DB) GetUserByEmail(ctx context.Context, email string) (*User, error) {
var user User
err := db.QueryRowContext(ctx, `
SELECT id, username, email, display_name, password_hash, created_at, last_login_at
FROM users
WHERE email = $1
`, email).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.PasswordHash, &user.CreatedAt, &user.LastLoginAt)
if err != nil {
return nil, err
}
return &user, nil
}
func (db *DB) GetUserByID(ctx context.Context, userID uuid.UUID) (*User, error) {
var user User
err := db.QueryRowContext(ctx, `
SELECT id, username, email, display_name, password_hash, created_at, last_login_at
FROM users
WHERE id = $1
`, userID).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.PasswordHash, &user.CreatedAt, &user.LastLoginAt)
if err != nil {
return nil, err
}
return &user, nil
}
func (db *DB) UpdateUserLastLogin(ctx context.Context, userID uuid.UUID) error {
_, err := db.ExecContext(ctx, `
UPDATE users
SET last_login_at = NOW()
WHERE id = $1
`, userID)
return err
}
func (db *DB) CreateCredential(ctx context.Context, cred *Credential) error {
_, err := db.ExecContext(ctx, `
INSERT INTO credentials (id, user_id, credential_public_key, credential_id, sign_count, transports)
VALUES ($1, $2, $3, $4, $5, $6)
`, cred.ID, cred.UserID, cred.CredentialPublicKey, cred.CredentialID, cred.SignCount, cred.Transports)
return err
}
func (db *DB) GetCredentialByID(ctx context.Context, credentialID []byte) (*Credential, error) {
var cred Credential
err := db.QueryRowContext(ctx, `
SELECT id, user_id, credential_public_key, credential_id, sign_count, created_at, last_used_at, transports
FROM credentials
WHERE credential_id = $1
`, credentialID).Scan(&cred.ID, &cred.UserID, &cred.CredentialPublicKey, &cred.CredentialID, &cred.SignCount, &cred.CreatedAt, &cred.LastUsedAt, &cred.Transports)
if err != nil {
return nil, err
}
return &cred, nil
}
func (db *DB) GetUserCredentials(ctx context.Context, userID uuid.UUID) ([]Credential, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, user_id, credential_public_key, credential_id, sign_count, created_at, last_used_at, transports
FROM credentials
WHERE user_id = $1
ORDER BY created_at DESC
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var credentials []Credential
for rows.Next() {
var cred Credential
err := rows.Scan(&cred.ID, &cred.UserID, &cred.CredentialPublicKey, &cred.CredentialID, &cred.SignCount, &cred.CreatedAt, &cred.LastUsedAt, &cred.Transports)
if err != nil {
return nil, err
}
credentials = append(credentials, cred)
}
return credentials, rows.Err()
}
func (db *DB) UpdateCredentialLastUsed(ctx context.Context, credentialID string) error {
_, err := db.ExecContext(ctx, `
UPDATE credentials
SET last_used_at = NOW()
WHERE id = $1
`, credentialID)
return err
}
func (db *DB) CreateAuthChallenge(ctx context.Context, userID uuid.UUID, challenge []byte, challengeType string) error {
_, err := db.ExecContext(ctx, `
INSERT INTO auth_challenges (user_id, challenge, challenge_type, expires_at)
VALUES ($1, $2, $3, NOW() + INTERVAL '15 minutes')
`, userID, challenge, challengeType)
return err
}
func (db *DB) VerifyAuthChallenge(ctx context.Context, userID uuid.UUID, challenge []byte, challengeType string) error {
var count int
err := db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM auth_challenges
WHERE user_id = $1 AND challenge = $2 AND challenge_type = $3 AND expires_at > NOW() AND used_at IS NULL
`, userID, challenge, challengeType).Scan(&count)
if err != nil {
return err
}
if count == 0 {
return sql.ErrNoRows
}
return nil
}
func (db *DB) MarkChallengeUsed(ctx context.Context, challenge []byte) error {
_, err := db.ExecContext(ctx, `
UPDATE auth_challenges
SET used_at = NOW()
WHERE challenge = $1 AND used_at IS NULL
`, challenge)
return err
}

View File

@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings" "strings"
"time"
"go.b0esche.cloud/backend/internal/audit" "go.b0esche.cloud/backend/internal/audit"
"go.b0esche.cloud/backend/internal/auth" "go.b0esche.cloud/backend/internal/auth"
@@ -33,15 +34,29 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut
// Auth routes (no auth required) // Auth routes (no auth required)
r.Route("/auth", func(r chi.Router) { r.Route("/auth", func(r chi.Router) {
r.Get("/login", func(w http.ResponseWriter, req *http.Request) {
authLoginHandler(w, req, authService)
})
r.Get("/callback", func(w http.ResponseWriter, req *http.Request) {
authCallbackHandler(w, req, cfg, authService, jwtManager, auditLogger, db)
})
r.Post("/refresh", func(w http.ResponseWriter, req *http.Request) { r.Post("/refresh", func(w http.ResponseWriter, req *http.Request) {
refreshHandler(w, req, jwtManager, db) refreshHandler(w, req, jwtManager, db)
}) })
// Passkey routes
r.Post("/signup", func(w http.ResponseWriter, req *http.Request) {
signupHandler(w, req, db, auditLogger)
})
r.Post("/registration-challenge", func(w http.ResponseWriter, req *http.Request) {
registrationChallengeHandler(w, req, db)
})
r.Post("/registration-verify", func(w http.ResponseWriter, req *http.Request) {
registrationVerifyHandler(w, req, db, jwtManager, auditLogger)
})
r.Post("/authentication-challenge", func(w http.ResponseWriter, req *http.Request) {
authenticationChallengeHandler(w, req, db)
})
r.Post("/authentication-verify", func(w http.ResponseWriter, req *http.Request) {
authenticationVerifyHandler(w, req, db, jwtManager, auditLogger)
})
// Password login route
r.Post("/password-login", func(w http.ResponseWriter, req *http.Request) {
passwordLoginHandler(w, req, db, jwtManager, auditLogger)
})
}) })
// Auth middleware for protected routes // Auth middleware for protected routes
@@ -96,67 +111,6 @@ func healthHandler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK")) w.Write([]byte("OK"))
} }
func authLoginHandler(w http.ResponseWriter, r *http.Request, authService *auth.Service) {
state, err := auth.GenerateState()
if err != nil {
errors.LogError(r, err, "Failed to generate state")
errors.WriteError(w, errors.CodeInternal, "Internal server error", http.StatusInternalServerError)
return
}
// TODO: Store state securely (e.g., in session or cache)
url := authService.LoginURL(state)
http.Redirect(w, r, url, http.StatusFound)
}
func authCallbackHandler(w http.ResponseWriter, r *http.Request, cfg *config.Config, authService *auth.Service, jwtManager *jwt.Manager, auditLogger *audit.Logger, db *database.DB) {
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
// TODO: Validate state
user, session, err := authService.HandleCallback(r.Context(), code, state)
if err != nil {
auditLogger.Log(r.Context(), audit.Entry{
Action: "login",
Success: false,
Metadata: map[string]interface{}{"error": err.Error()},
})
errors.LogError(r, err, "Authentication failed")
errors.WriteError(w, errors.CodeUnauthenticated, "Authentication failed", http.StatusUnauthorized)
return
}
// Get user orgs
orgs, err := org.ResolveUserOrgs(r.Context(), db, user.ID)
if err != nil {
errors.LogError(r, err, "Failed to resolve user orgs")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
orgIDs := make([]string, len(orgs))
for i, o := range orgs {
orgIDs[i] = o.ID.String()
}
token, err := jwtManager.Generate(user.Email, orgIDs, session.ID.String())
if err != nil {
errors.LogError(r, err, "Token generation failed")
errors.WriteError(w, errors.CodeInternal, "Token generation failed", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &user.ID,
Action: "login",
Success: true,
})
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"token": "` + token + `"}`))
}
func refreshHandler(w http.ResponseWriter, r *http.Request, jwtManager *jwt.Manager, db *database.DB) { func refreshHandler(w http.ResponseWriter, r *http.Request, jwtManager *jwt.Manager, db *database.DB) {
authHeader := r.Header.Get("Authorization") authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") { if !strings.HasPrefix(authHeader, "Bearer ") {
@@ -436,3 +390,345 @@ func fileMetaHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(meta) json.NewEncoder(w).Encode(meta)
} }
// Passkey handlers
func signupHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
var req struct {
Username string `json:"username"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
if req.Username == "" || req.Email == "" || req.Password == "" {
errors.WriteError(w, errors.CodeInvalidArgument, "Username, email, and password are required", http.StatusBadRequest)
return
}
// Hash password
passkeyService := auth.NewService(db)
passwordHash, err := passkeyService.HashPassword(req.Password)
if err != nil {
errors.LogError(r, err, "Failed to hash password")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Create user with hashed password
user, err := db.CreateUser(r.Context(), req.Username, req.Email, req.DisplayName, &passwordHash)
if err != nil {
errors.LogError(r, err, "Failed to create user")
if strings.Contains(err.Error(), "duplicate key") {
errors.WriteError(w, errors.CodeConflict, "Username or email already exists", http.StatusConflict)
} else {
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]interface{}{
"userId": user.ID,
"user": user,
})
}
func registrationChallengeHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
var req struct {
UserID string `json:"userId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
userID, err := uuid.Parse(req.UserID)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid user ID", http.StatusBadRequest)
return
}
passkeyService := auth.NewService(db)
challenge, err := passkeyService.StartRegistrationChallenge(r.Context(), userID)
if err != nil {
errors.LogError(r, err, "Failed to generate challenge")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"challenge": challenge,
"rp": map[string]string{
"name": auth.RPName,
"id": auth.RPID,
},
"user": map[string]string{
"id": userID.String(),
"name": userID.String(),
},
"pubKeyCredParams": []map[string]interface{}{
{"alg": -7, "type": "public-key"},
{"alg": -257, "type": "public-key"},
},
"timeout": 60000,
"attestation": "direct",
"authenticatorSelection": map[string]interface{}{
"authenticatorAttachment": "platform",
"requireResidentKey": false,
"userVerification": "preferred",
},
})
}
func registrationVerifyHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, auditLogger *audit.Logger) {
var req struct {
UserID string `json:"userId"`
Challenge string `json:"challenge"`
CredentialID string `json:"credentialId"`
PublicKey string `json:"publicKey"`
ClientDataJSON string `json:"clientDataJSON"`
AttestationObject string `json:"attestationObject"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
userID, err := uuid.Parse(req.UserID)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid user ID", http.StatusBadRequest)
return
}
passkeyService := auth.NewService(db)
_, err = passkeyService.VerifyRegistrationResponse(
r.Context(),
userID,
req.Challenge,
req.CredentialID,
req.PublicKey,
req.ClientDataJSON,
req.AttestationObject,
)
if err != nil {
errors.LogError(r, err, "Failed to verify registration")
errors.WriteError(w, errors.CodeUnauthenticated, "Registration failed: "+err.Error(), http.StatusBadRequest)
return
}
// Create session
session, err := db.CreateSession(r.Context(), userID, time.Now().Add(15*time.Minute))
if err != nil {
errors.LogError(r, err, "Failed to create session")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Get user
user, err := db.GetUserByID(r.Context(), userID)
if err != nil {
errors.LogError(r, err, "Failed to get user")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Generate JWT
orgIDs := []string{}
token, err := jwtManager.Generate(user.Email, orgIDs, session.ID.String())
if err != nil {
errors.LogError(r, err, "Token generation failed")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
Action: "registration",
Success: true,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"token": token,
"user": user,
})
}
func authenticationChallengeHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
var req struct {
Username string `json:"username"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
if req.Username == "" {
errors.WriteError(w, errors.CodeInvalidArgument, "Username is required", http.StatusBadRequest)
return
}
passkeyService := auth.NewService(db)
challenge, credentialIDs, err := passkeyService.StartAuthenticationChallenge(r.Context(), req.Username)
if err != nil {
errors.LogError(r, err, "Failed to generate challenge")
errors.WriteError(w, errors.CodeNotFound, "User not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"challenge": challenge,
"timeout": 60000,
"userVerification": "preferred",
"allowCredentials": credentialIDs,
})
}
func authenticationVerifyHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, auditLogger *audit.Logger) {
var req struct {
Username string `json:"username"`
Challenge string `json:"challenge"`
CredentialID string `json:"credentialId"`
AuthenticatorData string `json:"authenticatorData"`
ClientDataJSON string `json:"clientDataJSON"`
Signature string `json:"signature"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
passkeyService := auth.NewService(db)
user, err := passkeyService.VerifyAuthenticationResponse(
r.Context(),
req.Username,
req.Challenge,
req.CredentialID,
req.AuthenticatorData,
req.ClientDataJSON,
req.Signature,
)
if err != nil {
errors.LogError(r, err, "Failed to verify authentication")
errors.WriteError(w, errors.CodeUnauthenticated, "Authentication failed: "+err.Error(), http.StatusBadRequest)
return
}
// Create session
session, err := db.CreateSession(r.Context(), user.ID, time.Now().Add(15*time.Minute))
if err != nil {
errors.LogError(r, err, "Failed to create session")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Get user orgs
orgs, err := db.GetUserOrganizations(r.Context(), user.ID)
if err != nil {
errors.LogError(r, err, "Failed to get user orgs")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
orgIDs := make([]string, len(orgs))
for i, o := range orgs {
orgIDs[i] = o.ID.String()
}
// Generate JWT
token, err := jwtManager.Generate(user.Email, orgIDs, session.ID.String())
if err != nil {
errors.LogError(r, err, "Token generation failed")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &user.ID,
Action: "login",
Success: true,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"token": token,
"user": user,
})
}
func passwordLoginHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, auditLogger *audit.Logger) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
if req.Username == "" || req.Password == "" {
errors.WriteError(w, errors.CodeInvalidArgument, "Username and password are required", http.StatusBadRequest)
return
}
// Verify password
passkeyService := auth.NewService(db)
user, err := passkeyService.VerifyPasswordLogin(r.Context(), req.Username, req.Password)
if err != nil {
auditLogger.Log(r.Context(), audit.Entry{
Action: "login",
Success: false,
Metadata: map[string]interface{}{"error": err.Error()},
})
errors.LogError(r, err, "Password login failed")
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid credentials", http.StatusUnauthorized)
return
}
// Create session
session, err := db.CreateSession(r.Context(), user.ID, time.Now().Add(15*time.Minute))
if err != nil {
errors.LogError(r, err, "Failed to create session")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Get user orgs
orgs, err := db.GetUserOrganizations(r.Context(), user.ID)
if err != nil {
errors.LogError(r, err, "Failed to get user orgs")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
orgIDs := make([]string, len(orgs))
for i, o := range orgs {
orgIDs[i] = o.ID.String()
}
// Generate JWT
token, err := jwtManager.Generate(user.Email, orgIDs, session.ID.String())
if err != nil {
errors.LogError(r, err, "Token generation failed")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &user.ID,
Action: "login",
Success: true,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"token": token,
"user": user,
})
}

View File

@@ -0,0 +1,31 @@
-- Add username to users table
ALTER TABLE users
ADD COLUMN username TEXT UNIQUE,
ADD COLUMN password_hash TEXT;
-- Create credentials table for WebAuthn passkeys
CREATE TABLE credentials (
id TEXT PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
credential_public_key BYTEA NOT NULL,
credential_id BYTEA NOT NULL UNIQUE,
sign_count BIGINT DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
last_used_at TIMESTAMP WITH TIME ZONE,
transports TEXT[] DEFAULT ARRAY[]::TEXT[]
);
-- Create table for WebAuthn registration/authentication challenges
CREATE TABLE auth_challenges (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
challenge BYTEA NOT NULL,
challenge_type TEXT NOT NULL, -- 'registration' or 'authentication'
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
used_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_credentials_user_id ON credentials(user_id);
CREATE INDEX idx_auth_challenges_user_id ON auth_challenges(user_id);
CREATE INDEX idx_auth_challenges_expires_at ON auth_challenges(expires_at);