diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..a7d67c8 Binary files /dev/null and b/.DS_Store differ diff --git a/b0esche_cloud/lib/blocs/auth/auth_bloc.dart b/b0esche_cloud/lib/blocs/auth/auth_bloc.dart index 4ff9950..97f6b74 100644 --- a/b0esche_cloud/lib/blocs/auth/auth_bloc.dart +++ b/b0esche_cloud/lib/blocs/auth/auth_bloc.dart @@ -1,38 +1,243 @@ import 'package:bloc/bloc.dart'; +import '../session/session_bloc.dart'; +import '../session/session_event.dart'; import 'auth_event.dart'; import 'auth_state.dart'; +import '../../services/api_client.dart'; class AuthBloc extends Bloc { - AuthBloc() : super(AuthInitial()) { + final ApiClient apiClient; + final SessionBloc sessionBloc; + + AuthBloc({required this.apiClient, required this.sessionBloc}) + : super(AuthInitial()) { + on(_onSignupStarted); + on(_onRegistrationChallengeRequested); + on(_onRegistrationResponseSubmitted); on(_onLoginRequested); + on(_onPasswordLoginRequested); + on(_onAuthenticationChallengeRequested); + on(_onAuthenticationResponseSubmitted); on(_onLogoutRequested); on(_onCheckAuthRequested); } - void _onLoginRequested(LoginRequested event, Emitter emit) async { - emit(AuthLoading()); - // Redirect to Go auth/login - // For web, use window.location or url_launcher - // Assume handled in UI - emit(const AuthFailure('Redirect to login URL')); + Future _onSignupStarted( + SignupStarted event, + Emitter emit, + ) async { + emit(AuthLoading(message: 'Creating account...')); + 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 _onRegistrationChallengeRequested( + RegistrationChallengeRequested event, + Emitter 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 _onRegistrationResponseSubmitted( + RegistrationResponseSubmitted event, + Emitter 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 _onLoginRequested( + LoginRequested event, + Emitter emit, + ) async { + emit(AuthLoading(message: 'Requesting authentication challenge...')); + try { + add(AuthenticationChallengeRequested(username: event.username)); + } catch (e) { + emit(AuthFailure(e.toString())); + } + } + + Future _onAuthenticationChallengeRequested( + AuthenticationChallengeRequested event, + Emitter 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 _onAuthenticationResponseSubmitted( + AuthenticationResponseSubmitted event, + Emitter 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 _onLogoutRequested( LogoutRequested event, Emitter emit, ) async { - emit(AuthLoading()); - // Call logout API + emit(AuthLoading(message: 'Logging out...')); await Future.delayed(const Duration(milliseconds: 500)); + sessionBloc.add(SessionExpired()); emit(AuthUnauthenticated()); } - void _onCheckAuthRequested( + Future _onPasswordLoginRequested( + PasswordLoginRequested event, + Emitter 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 _onCheckAuthRequested( CheckAuthRequested event, Emitter emit, ) async { - // Check if token is valid - // For now, assume unauthenticated + // Check if token is valid in SessionBloc emit(AuthUnauthenticated()); } } diff --git a/b0esche_cloud/lib/blocs/auth/auth_event.dart b/b0esche_cloud/lib/blocs/auth/auth_event.dart index 6d9f8bd..97cf85c 100644 --- a/b0esche_cloud/lib/blocs/auth/auth_event.dart +++ b/b0esche_cloud/lib/blocs/auth/auth_event.dart @@ -3,12 +3,135 @@ import 'package:equatable/equatable.dart'; abstract class AuthEvent extends Equatable { const AuthEvent(); + @override + List 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 get props => [username, email, displayName, password]; +} + +class RegistrationChallengeRequested extends AuthEvent { + final String userId; + + const RegistrationChallengeRequested({required this.userId}); + + @override + List 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 get props => [ + userId, + challenge, + credentialId, + publicKey, + clientDataJSON, + attestationObject, + ]; +} + +// Login events +class LoginRequested extends AuthEvent { + final String username; + + const LoginRequested({required this.username}); + + @override + List get props => [username]; +} + +class AuthenticationChallengeRequested extends AuthEvent { + final String username; + + const AuthenticationChallengeRequested({required this.username}); + + @override + List 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 get props => [ + username, + challenge, + credentialId, + authenticatorData, + clientDataJSON, + signature, + ]; +} + +class LogoutRequested extends AuthEvent { + const LogoutRequested(); + @override List get props => []; } -class LoginRequested extends AuthEvent {} +class CheckAuthRequested extends AuthEvent { + const CheckAuthRequested(); -class LogoutRequested extends AuthEvent {} + @override + List 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 get props => [username, password]; +} diff --git a/b0esche_cloud/lib/blocs/auth/auth_state.dart b/b0esche_cloud/lib/blocs/auth/auth_state.dart index cbffa93..d5473f1 100644 --- a/b0esche_cloud/lib/blocs/auth/auth_state.dart +++ b/b0esche_cloud/lib/blocs/auth/auth_state.dart @@ -4,25 +4,82 @@ abstract class AuthState extends Equatable { const AuthState(); @override - List get props => []; + List get props => []; } class AuthInitial extends AuthState {} -class AuthLoading extends AuthState {} +class AuthLoading extends AuthState { + final String? message; + + const AuthLoading({this.message}); + + @override + List 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 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 get props => [userId, challenge, rpName, rpId]; +} + +class AuthenticationChallengeReceived extends AuthState { + final String challenge; + final List credentialIds; + + const AuthenticationChallengeReceived({ + required this.challenge, + required this.credentialIds, + }); + + @override + List get props => [challenge, credentialIds]; +} class AuthAuthenticated extends AuthState { final String token; 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 - List get props => [token, userId]; + List get props => [token, userId, username, email]; } -class AuthUnauthenticated extends AuthState {} - class AuthFailure extends AuthState { final String error; @@ -31,3 +88,10 @@ class AuthFailure extends AuthState { @override List get props => [error]; } + +class AuthUnauthenticated extends AuthState { + const AuthUnauthenticated(); + + @override + List get props => []; +} diff --git a/b0esche_cloud/lib/main.dart b/b0esche_cloud/lib/main.dart index bc32cd7..8f7637a 100644 --- a/b0esche_cloud/lib/main.dart +++ b/b0esche_cloud/lib/main.dart @@ -3,18 +3,12 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'blocs/auth/auth_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 'repositories/mock_file_repository.dart'; -import 'services/file_service.dart'; import 'services/api_client.dart'; -import 'services/org_api.dart'; import 'services/activity_api.dart'; import 'pages/home_page.dart'; import 'pages/login_form.dart'; +import 'pages/signup_form.dart'; import 'pages/file_explorer.dart'; import 'pages/document_viewer.dart'; import 'pages/editor_page.dart'; @@ -24,6 +18,7 @@ final GoRouter _router = GoRouter( routes: [ GoRoute(path: '/', builder: (context, state) => const HomePage()), GoRoute(path: '/login', builder: (context, state) => const LoginForm()), + GoRoute(path: '/signup', builder: (context, state) => const SignupForm()), GoRoute( path: '/viewer/:orgId/:fileId', @@ -58,25 +53,11 @@ class MainApp extends StatelessWidget { Widget build(BuildContext context) { return MultiBlocProvider( providers: [ - BlocProvider(create: (_) => AuthBloc()), BlocProvider(create: (_) => SessionBloc()), - BlocProvider(create: (_) => PermissionBloc()), - BlocProvider( - create: (context) => FileBrowserBloc( - FileService(ApiClient(context.read())), - ), - ), - BlocProvider( - create: (_) => - UploadBloc(MockFileRepository()), // keep mock for upload - ), - BlocProvider( - lazy: true, - create: (context) => OrganizationBloc( - context.read(), - context.read(), - context.read(), - OrgApi(ApiClient(context.read())), + BlocProvider( + create: (context) => AuthBloc( + apiClient: ApiClient(context.read()), + sessionBloc: context.read(), ), ), BlocProvider( diff --git a/b0esche_cloud/lib/models/credential.dart b/b0esche_cloud/lib/models/credential.dart new file mode 100644 index 0000000..ff0fad5 --- /dev/null +++ b/b0esche_cloud/lib/models/credential.dart @@ -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? 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 get props => [ + id, + userId, + credentialId, + publicKey, + signCount, + createdAt, + lastUsedAt, + transports, + ]; + + factory Credential.fromJson(Map 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?)?.cast(), + ); + } + + Map toJson() { + return { + 'id': id, + 'userId': userId, + 'credentialId': credentialId, + 'publicKey': publicKey, + 'signCount': signCount, + 'createdAt': createdAt.toIso8601String(), + 'lastUsedAt': lastUsedAt?.toIso8601String(), + 'transports': transports, + }; + } +} diff --git a/b0esche_cloud/lib/models/user.dart b/b0esche_cloud/lib/models/user.dart index 93824c9..55655ac 100644 --- a/b0esche_cloud/lib/models/user.dart +++ b/b0esche_cloud/lib/models/user.dart @@ -1,15 +1,73 @@ import 'package:equatable/equatable.dart'; class User extends Equatable { + final String id; + final String username; 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 - List get props => [email, name]; + List get props => [ + id, + username, + email, + displayName, + createdAt, + lastLoginAt, + ]; - User copyWith({String? email, String? name}) { - return User(email: email ?? this.email, name: name ?? this.name); + User copyWith({ + 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 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 toJson() { + return { + 'id': id, + 'username': username, + 'email': email, + 'displayName': displayName, + 'createdAt': createdAt.toIso8601String(), + 'lastLoginAt': lastLoginAt?.toIso8601String(), + }; } } diff --git a/b0esche_cloud/lib/pages/home_page.dart b/b0esche_cloud/lib/pages/home_page.dart index e3864dd..65925d4 100644 --- a/b0esche_cloud/lib/pages/home_page.dart +++ b/b0esche_cloud/lib/pages/home_page.dart @@ -10,7 +10,7 @@ import '../blocs/file_browser/file_browser_bloc.dart'; import '../blocs/file_browser/file_browser_event.dart'; import '../theme/app_theme.dart'; import '../theme/modern_glass_button.dart'; -import 'login_form.dart'; +import 'login_form.dart' show LoginForm; import 'file_explorer.dart'; class HomePage extends StatefulWidget { diff --git a/b0esche_cloud/lib/pages/login_form.dart b/b0esche_cloud/lib/pages/login_form.dart index af0025e..5e3eecc 100644 --- a/b0esche_cloud/lib/pages/login_form.dart +++ b/b0esche_cloud/lib/pages/login_form.dart @@ -1,5 +1,7 @@ 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'; @@ -16,8 +18,55 @@ class LoginForm extends StatefulWidget { } class _LoginFormState extends State { - final _emailController = TextEditingController(); + final _usernameController = 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.generate(bytes, (i) => random.nextInt(256)); + return values.map((v) => v.toRadixString(16).padLeft(2, '0')).join(); + } + + Future _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().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 Widget build(BuildContext context) { @@ -27,62 +76,69 @@ class _LoginFormState extends State { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(state.error))); + } else if (state is AuthenticationChallengeReceived) { + _handleAuthentication(context, state); } else if (state is AuthAuthenticated) { // Start session context.read().add(SessionStarted(state.token)); + context.go('/'); } }, child: Center( child: Padding( padding: const EdgeInsets.all(16.0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Text( - 'sign in', - style: TextStyle(fontSize: 24, color: AppTheme.primaryText), - ), - const SizedBox(height: 16), - 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: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Text( + 'sign in', + 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: 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( - children: [ - TextField( - controller: _emailController, - textInputAction: TextInputAction.next, - keyboardType: TextInputType.emailAddress, - cursorColor: AppTheme.accentColor, - decoration: InputDecoration( - 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, - ), + const SizedBox(height: 16), + if (!_usePasskey) + Container( + decoration: BoxDecoration( + color: AppTheme.primaryBackground.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: AppTheme.accentColor.withValues(alpha: 0.3), ), - style: const TextStyle(color: AppTheme.primaryText), ), - Divider( - color: AppTheme.accentColor.withValues(alpha: 0.2), - height: 1, - thickness: 1, - ), - TextField( + child: TextField( controller: _passwordController, textInputAction: TextInputAction.done, keyboardType: TextInputType.visiblePassword, obscureText: true, - obscuringCharacter: '✱', cursorColor: AppTheme.accentColor, decoration: InputDecoration( hintText: 'password', @@ -90,37 +146,96 @@ class _LoginFormState extends State { contentPadding: const EdgeInsets.all(12), border: InputBorder.none, prefixIcon: Icon( - Icons.lock_outline_rounded, + Icons.lock_outline, color: AppTheme.primaryText, size: 20, ), ), style: const TextStyle(color: AppTheme.primaryText), ), + ), + if (!_usePasskey) const SizedBox(height: 16), + SizedBox( + width: 150, + child: BlocBuilder( + 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().add( + LoginRequested(username: _usernameController.text), + ); + } else { + context.read().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), - SizedBox( - width: 150, - child: BlocBuilder( - builder: (context, state) { - return ModernGlassButton( - isLoading: state is AuthLoading, - onPressed: () { - context.read().add( - LoginRequested( - // _emailController.text, - // _passwordController.text, - ), - ); - }, - child: const Text('sign in'), - ); - }, + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'don\'t have an account?', + style: TextStyle(color: AppTheme.secondaryText), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: () => context.go('/signup'), + child: Text( + 'create one', + style: TextStyle( + color: AppTheme.accentColor, + decoration: TextDecoration.underline, + ), + ), + ), + ], ), - ), - ], + ], + ), ), ), ), diff --git a/b0esche_cloud/lib/pages/signup_form.dart b/b0esche_cloud/lib/pages/signup_form.dart new file mode 100644 index 0000000..c74619d --- /dev/null +++ b/b0esche_cloud/lib/pages/signup_form.dart @@ -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 createState() => _SignupFormState(); +} + +class _SignupFormState extends State { + 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.generate(bytes, (i) => random.nextInt(256)); + return values.map((v) => v.toRadixString(16).padLeft(2, '0')).join(); + } + + Future _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().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( + 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( + 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().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, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/b0esche_cloud/lib/repositories/mock_auth_repository.dart b/b0esche_cloud/lib/repositories/mock_auth_repository.dart index a493720..3b8e9c7 100644 --- a/b0esche_cloud/lib/repositories/mock_auth_repository.dart +++ b/b0esche_cloud/lib/repositories/mock_auth_repository.dart @@ -6,7 +6,12 @@ class MockAuthRepository implements AuthRepository { Future login(String email, String password) async { await Future.delayed(const Duration(seconds: 1)); if (email.isNotEmpty && password.isNotEmpty) { - return User(email: email); + return User( + id: 'mock-user-id', + username: 'mockuser', + email: email, + createdAt: DateTime.now(), + ); } else { throw Exception('Invalid credentials'); } diff --git a/b0esche_cloud/lib/services/file_service.dart b/b0esche_cloud/lib/services/file_service.dart index 4ddb87f..792df0e 100644 --- a/b0esche_cloud/lib/services/file_service.dart +++ b/b0esche_cloud/lib/services/file_service.dart @@ -2,7 +2,6 @@ import '../models/file_item.dart'; import '../models/viewer_session.dart'; import '../models/editor_session.dart'; import '../models/annotation.dart'; -import '../repositories/file_repository.dart'; import 'api_client.dart'; class FileService { diff --git a/b0esche_cloud/pubspec.yaml b/b0esche_cloud/pubspec.yaml index 9ee3326..4ff331a 100644 --- a/b0esche_cloud/pubspec.yaml +++ b/b0esche_cloud/pubspec.yaml @@ -37,8 +37,7 @@ dependencies: logger: ^2.0.2 # Image Handling - cached_network_image: - ^3.3.0 + cached_network_image: ^3.3.0 # SVG Support flutter_svg: ^2.0.9 diff --git a/go_cloud/api b/go_cloud/api index 49ef077..aa383ce 100755 Binary files a/go_cloud/api and b/go_cloud/api differ diff --git a/go_cloud/cmd/api/main.go b/go_cloud/cmd/api/main.go index 3defd37..bbef97d 100644 --- a/go_cloud/cmd/api/main.go +++ b/go_cloud/cmd/api/main.go @@ -25,11 +25,7 @@ func main() { jwtManager := jwt.NewManager(cfg.JWTSecret) - authService, err := auth.NewService(cfg, db) - if err != nil { - fmt.Fprintf(os.Stderr, "Auth service error: %v\n", err) - os.Exit(1) - } + authService := auth.NewService(db) auditLogger := audit.NewLogger(db) diff --git a/go_cloud/internal/auth/auth.go b/go_cloud/internal/auth/auth.go index ca56c80..977d4f2 100644 --- a/go_cloud/internal/auth/auth.go +++ b/go_cloud/internal/auth/auth.go @@ -14,13 +14,13 @@ import ( "golang.org/x/oauth2" ) -type Service struct { +type OIDCService struct { provider *oidc.Provider oauth2Config oauth2.Config 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() 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"}, } - return &Service{ + return &OIDCService{ provider: provider, oauth2Config: oauth2Config, db: db, }, nil } -func (s *Service) LoginURL(state string) string { +func (s *OIDCService) LoginURL(state string) string { 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) if err != nil { return nil, nil, fmt.Errorf("failed to exchange code: %w", err) diff --git a/go_cloud/internal/auth/passkey.go b/go_cloud/internal/auth/passkey.go new file mode 100644 index 0000000..5576ce2 --- /dev/null +++ b/go_cloud/internal/auth/passkey.go @@ -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 +} diff --git a/go_cloud/internal/database/db.go b/go_cloud/internal/database/db.go index 2f235b5..a5bfd9f 100644 --- a/go_cloud/internal/database/db.go +++ b/go_cloud/internal/database/db.go @@ -17,11 +17,34 @@ func New(db *sql.DB) *DB { } type User struct { - ID uuid.UUID - Email string - DisplayName string - CreatedAt time.Time - LastLoginAt *time.Time + ID uuid.UUID + Email string + Username string + DisplayName string + 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 { @@ -218,3 +241,153 @@ func (db *DB) UpdateMemberRole(ctx context.Context, orgID, userID uuid.UUID, rol `, role, orgID, userID) 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 +} diff --git a/go_cloud/internal/http/routes.go b/go_cloud/internal/http/routes.go index 8f7a00b..ffb5903 100644 --- a/go_cloud/internal/http/routes.go +++ b/go_cloud/internal/http/routes.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "strings" + "time" "go.b0esche.cloud/backend/internal/audit" "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) 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) { 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 @@ -96,67 +111,6 @@ func healthHandler(w http.ResponseWriter, r *http.Request) { 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) { authHeader := r.Header.Get("Authorization") if !strings.HasPrefix(authHeader, "Bearer ") { @@ -436,3 +390,345 @@ func fileMetaHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") 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, + }) +} diff --git a/go_cloud/migrations/0002_passkeys.sql b/go_cloud/migrations/0002_passkeys.sql new file mode 100644 index 0000000..879c666 --- /dev/null +++ b/go_cloud/migrations/0002_passkeys.sql @@ -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);