Start writing the web app
All checks were successful
ci/woodpecker/push/build Pipeline was successful
All checks were successful
ci/woodpecker/push/build Pipeline was successful
Signed-off-by: Nikolai Rodionov <allanger@posteo.com>
This commit was merged in pull request #5.
This commit is contained in:
14
lib/core/api/v1/accounts.dart
Normal file
14
lib/core/api/v1/accounts.dart
Normal file
@@ -0,0 +1,14 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:grpc/grpc_web.dart';
|
||||
import 'package:protobuf/well_known_types/google/protobuf/empty.pb.dart';
|
||||
import 'package:softplayer_dart_proto/accounts/v1/accounts_v1.pbgrpc.dart';
|
||||
import 'package:softplayer_web/core/grpc/grpc_client.dart';
|
||||
|
||||
final accountsGrpcProvider = Provider<AccountsGrpcRepository>((ref) {
|
||||
return AccountsGrpcRepository(ref.watch(accountsServiceClientProvider));
|
||||
});
|
||||
|
||||
class AccountsGrpcRepository {
|
||||
AccountsGrpcRepository(this._client);
|
||||
final AccountsServiceClient _client;
|
||||
}
|
||||
35
lib/core/api/v1/public_accounts.dart
Normal file
35
lib/core/api/v1/public_accounts.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:grpc/grpc_web.dart';
|
||||
import 'package:softplayer_dart_proto/accounts/v1/accounts_v1.pbgrpc.dart';
|
||||
import 'package:softplayer_web/core/grpc/grpc_client.dart';
|
||||
|
||||
final publicAccountsGrpcProvider = Provider<PublicAccountsGrpcRepository>((
|
||||
ref,
|
||||
) {
|
||||
return PublicAccountsGrpcRepository(
|
||||
ref.watch(publicAccountsServiceClientProvider),
|
||||
);
|
||||
});
|
||||
|
||||
class PublicAccountsGrpcRepository {
|
||||
PublicAccountsGrpcRepository(this._client);
|
||||
final PublicAccountsServiceClient _client;
|
||||
|
||||
ResponseFuture<SignInResponse> signIn(SignInRequest req) {
|
||||
try {
|
||||
final response = _client.signIn(req);
|
||||
return response;
|
||||
} catch (error) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
ResponseFuture<SignUpResponse> signUp(SignUpRequest req) {
|
||||
try {
|
||||
final response = _client.signUp(req);
|
||||
return response;
|
||||
} catch (error) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
28
lib/core/api/v1/refresh_session.dart
Normal file
28
lib/core/api/v1/refresh_session.dart
Normal file
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:grpc/grpc_web.dart';
|
||||
import 'package:softplayer_dart_proto/accounts/v1/accounts_v1.pbgrpc.dart';
|
||||
import 'package:softplayer_web/core/grpc/grpc_client.dart';
|
||||
|
||||
final refreshSessionGrpcProvider = Provider<RefreshSessionGrpcRepository>((
|
||||
ref,
|
||||
) {
|
||||
return RefreshSessionGrpcRepository(
|
||||
ref.watch(refreshSessionServiceClientProvider),
|
||||
);
|
||||
});
|
||||
|
||||
class RefreshSessionGrpcRepository {
|
||||
final RefreshSessionServiceClient _client;
|
||||
RefreshSessionGrpcRepository(this._client);
|
||||
|
||||
ResponseFuture<RefreshSessionResponse> refreshSession(String refreshToken) {
|
||||
try {
|
||||
final response = _client.refreshSession(
|
||||
RefreshSessionRequest(refreshToken: refreshToken),
|
||||
);
|
||||
return response;
|
||||
} catch (error) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
22
lib/core/api/v1/test.dart
Normal file
22
lib/core/api/v1/test.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:grpc/grpc_web.dart';
|
||||
import 'package:softplayer_dart_proto/test/v1/test_v1.pbgrpc.dart';
|
||||
import 'package:softplayer_web/core/grpc/grpc_client.dart';
|
||||
|
||||
final testGrpcProvider = Provider<TestGrpcRepository>((ref) {
|
||||
return TestGrpcRepository(ref.watch(testServiceClientProvider));
|
||||
});
|
||||
|
||||
class TestGrpcRepository {
|
||||
TestGrpcRepository(this._client);
|
||||
final TestServiceClient _client;
|
||||
|
||||
ResponseFuture<PongResponse> pong() {
|
||||
try {
|
||||
final response = _client.pong(PongRequest());
|
||||
return response;
|
||||
} catch (error) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
71
lib/core/grpc/grpc_auth_interceptor.dart
Normal file
71
lib/core/grpc/grpc_auth_interceptor.dart
Normal file
@@ -0,0 +1,71 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:grpc/grpc.dart';
|
||||
|
||||
class AuthInterceptor extends ClientInterceptor {
|
||||
final String Function() getAccessToken;
|
||||
final String Function() getRefreshToken;
|
||||
|
||||
AuthInterceptor({
|
||||
required this.getAccessToken,
|
||||
required this.getRefreshToken,
|
||||
});
|
||||
|
||||
bool _isPublicEndpoint(String path) {
|
||||
return path.contains('/Public');
|
||||
}
|
||||
|
||||
bool _isRefreshEndpoint(String path) {
|
||||
return path.contains('/RefreshToken');
|
||||
}
|
||||
|
||||
Map<String, String> _buildMetadata(String path) {
|
||||
final metadata = <String, String>{};
|
||||
|
||||
// Public endpoints → no token
|
||||
if (_isPublicEndpoint(path)) {
|
||||
log("Public endpoint, no tokens needed");
|
||||
return metadata;
|
||||
}
|
||||
|
||||
String? token;
|
||||
|
||||
// Refresh endpoint → use refresh token
|
||||
if (_isRefreshEndpoint(path)) {
|
||||
log("Adding a refresh token to the metadata");
|
||||
token = getRefreshToken();
|
||||
} else {
|
||||
log("Adding an acces token to the metadata");
|
||||
token = getAccessToken();
|
||||
}
|
||||
|
||||
if (token.isNotEmpty) {
|
||||
metadata['authorization'] = 'Bearer $token';
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
@override
|
||||
ResponseStream<R> interceptStreaming<Q, R>(
|
||||
ClientMethod<Q, R> method,
|
||||
Stream<Q> requests,
|
||||
CallOptions options,
|
||||
ClientStreamingInvoker<Q, R> invoker,
|
||||
) {
|
||||
// TODO: implement interceptStreaming
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
ResponseFuture<R> interceptUnary<Q, R>(
|
||||
ClientMethod<Q, R> method,
|
||||
Q request,
|
||||
CallOptions options,
|
||||
ClientUnaryInvoker<Q, R> invoker,
|
||||
) {
|
||||
final modifiedOptions = options.mergedWith(
|
||||
CallOptions(metadata: _buildMetadata(method.path)),
|
||||
);
|
||||
return super.interceptUnary(method, request, modifiedOptions, invoker);
|
||||
}
|
||||
}
|
||||
16
lib/core/grpc/grpc_channel_provider.dart
Normal file
16
lib/core/grpc/grpc_channel_provider.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:grpc/grpc_web.dart';
|
||||
|
||||
final grpcChannelProvider = Provider<GrpcWebClientChannel>((ref) {
|
||||
String backendURL = dotenv.env['SOFTPLAYER_BACKEND_URL']!;
|
||||
final GrpcWebClientChannel channel = GrpcWebClientChannel.xhr(
|
||||
Uri.parse(backendURL),
|
||||
);
|
||||
|
||||
ref.onDispose(() async {
|
||||
await channel.shutdown();
|
||||
});
|
||||
|
||||
return channel;
|
||||
});
|
||||
54
lib/core/grpc/grpc_client.dart
Normal file
54
lib/core/grpc/grpc_client.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:softplayer_dart_proto/accounts/v1/accounts_v1.pbgrpc.dart';
|
||||
import 'package:softplayer_dart_proto/test/v1/test_v1.pbgrpc.dart';
|
||||
import 'package:softplayer_web/core/grpc/grpc_auth_interceptor.dart';
|
||||
import 'package:softplayer_web/core/tokens/application/tokens_application.dart';
|
||||
|
||||
import 'grpc_channel_provider.dart';
|
||||
|
||||
final testServiceClientProvider = Provider<TestServiceClient>((ref) {
|
||||
final channel = ref.watch(grpcChannelProvider);
|
||||
final tokenState = ref.read(tokensControllerProvider).value;
|
||||
if (tokenState == null) {
|
||||
throw Exception("Token state is not initialized");
|
||||
}
|
||||
|
||||
return TestServiceClient(
|
||||
channel,
|
||||
interceptors: [
|
||||
AuthInterceptor(
|
||||
getAccessToken: tokenState.getAccessToken,
|
||||
getRefreshToken: tokenState.getRefreshToken,
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
final accountsServiceClientProvider = Provider<AccountsServiceClient>((ref) {
|
||||
final channel = ref.watch(grpcChannelProvider);
|
||||
final tokenState = ref.read(tokensControllerProvider).value;
|
||||
if (tokenState == null) {
|
||||
throw Exception("Token state is not initialized");
|
||||
}
|
||||
return AccountsServiceClient(
|
||||
channel,
|
||||
interceptors: [
|
||||
AuthInterceptor(
|
||||
getAccessToken: tokenState.getAccessToken,
|
||||
getRefreshToken: tokenState.getRefreshToken,
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
final publicAccountsServiceClientProvider =
|
||||
Provider<PublicAccountsServiceClient>((ref) {
|
||||
final channel = ref.watch(grpcChannelProvider);
|
||||
return PublicAccountsServiceClient(channel);
|
||||
});
|
||||
|
||||
final refreshSessionServiceClientProvider =
|
||||
Provider<RefreshSessionServiceClient>((ref) {
|
||||
final channel = ref.watch(grpcChannelProvider);
|
||||
return RefreshSessionServiceClient(channel);
|
||||
});
|
||||
30
lib/core/router/app_router.dart
Normal file
30
lib/core/router/app_router.dart
Normal file
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:softplayer_web/core/tokens/application/tokens_application.dart';
|
||||
import 'package:softplayer_web/features/authorization/application/authorization_application.dart';
|
||||
import 'package:softplayer_web/features/authorization/presentation/authorization_page.dart';
|
||||
import 'package:softplayer_web/features/dashboard/presentation/dashboard_page.dart';
|
||||
|
||||
final goRouterProvider = Provider<GoRouter>((ref) {
|
||||
final authState = ref.watch(authorizationControllerProvider);
|
||||
final _tokenCtrl = ref.watch(tokensControllerProvider);
|
||||
|
||||
// If not authorized, redirect to the auth page, otherwise dashboard
|
||||
return GoRouter(
|
||||
initialLocation: '/',
|
||||
redirect: (context, state) {
|
||||
final isAuthorized = authState.requireValue.isAuthorized;
|
||||
if (!isAuthorized) {
|
||||
return "/auth";
|
||||
}
|
||||
return "/";
|
||||
},
|
||||
routes: [
|
||||
GoRoute(path: '/', builder: (context, state) => const DashboardPage()),
|
||||
GoRoute(
|
||||
path: '/auth',
|
||||
builder: (context, state) => const AuthorizationPage(),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
167
lib/core/tokens/application/tokens_application.dart
Normal file
167
lib/core/tokens/application/tokens_application.dart
Normal file
@@ -0,0 +1,167 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:jwt_decoder/jwt_decoder.dart';
|
||||
import 'package:softplayer_dart_proto/accounts/v1/accounts_v1.pb.dart';
|
||||
import 'package:softplayer_web/core/api/v1/accounts.dart';
|
||||
import 'package:softplayer_web/core/api/v1/refresh_session.dart';
|
||||
import 'package:softplayer_web/core/grpc/grpc_client.dart';
|
||||
import 'package:softplayer_web/core/tokens/data/token_storage_repository.dart';
|
||||
import 'package:softplayer_web/features/authorization/application/authorization_application.dart';
|
||||
|
||||
class TokenState {
|
||||
final String? accessToken;
|
||||
final String? refreshToken;
|
||||
const TokenState({this.accessToken, this.refreshToken});
|
||||
|
||||
TokenState copyWith({String? accessToken, String? refreshToken}) {
|
||||
return TokenState(
|
||||
refreshToken: refreshToken ?? this.refreshToken,
|
||||
accessToken: accessToken ?? this.accessToken,
|
||||
);
|
||||
}
|
||||
|
||||
// Get an access token from the state
|
||||
String getAccessToken() {
|
||||
return accessToken ?? "";
|
||||
}
|
||||
|
||||
// Get a refresh token from the state
|
||||
String getRefreshToken() {
|
||||
return refreshToken ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
const accessTokenHeader = "x-access-token";
|
||||
const refreshTokenHeader = "x-refresh-token";
|
||||
|
||||
class Tokens {
|
||||
final String accessToken;
|
||||
final String refreshToken;
|
||||
|
||||
const Tokens({required this.accessToken, required this.refreshToken});
|
||||
|
||||
factory Tokens.fromProto(TokenPair pair) {
|
||||
return Tokens(
|
||||
accessToken: pair.accessToken,
|
||||
refreshToken: pair.refreshToken,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final tokensControllerProvider =
|
||||
AsyncNotifierProvider<TokensController, TokenState>(TokensController.new);
|
||||
|
||||
class TokensController extends AsyncNotifier<TokenState> {
|
||||
@override
|
||||
Future<TokenState> build() async {
|
||||
final tokenRepo = ref.read(tokenStorageRepositoryProvider);
|
||||
final accessToken = await tokenRepo.getAccessToken();
|
||||
final refreshToken = await tokenRepo.getRefreshToken();
|
||||
|
||||
// If refresh token is not valid, we just drop the session,
|
||||
// even if there is an active access token, and return
|
||||
// an empty state
|
||||
if (!verifyJWT(refreshToken)) {
|
||||
log("Refresh token is not valid, logging out");
|
||||
await tokenRepo.clearStorage();
|
||||
return TokenState();
|
||||
}
|
||||
|
||||
// If access token is not valid, refresh it using the refresh token
|
||||
if (verifyJWT(accessToken)) {
|
||||
log("Access token is valid");
|
||||
return TokenState(refreshToken: refreshToken, accessToken: accessToken);
|
||||
}
|
||||
log("Only refresh token is valid");
|
||||
return TokenState(refreshToken: refreshToken);
|
||||
}
|
||||
|
||||
Future<void> resetTokens() async {
|
||||
final tokenRepo = ref.read(tokenStorageRepositoryProvider);
|
||||
await tokenRepo.clearStorage();
|
||||
state = await AsyncValue.guard(() async {
|
||||
return TokenState();
|
||||
});
|
||||
}
|
||||
|
||||
// Store an access token to the storage and save it to the state
|
||||
Future<void> writeAccessToken(String token) async {
|
||||
final tokenRepo = ref.read(tokenStorageRepositoryProvider);
|
||||
await tokenRepo.storeAccessToken(token);
|
||||
state = await AsyncValue.guard(() async {
|
||||
return state.value!.copyWith(accessToken: token);
|
||||
});
|
||||
}
|
||||
|
||||
// Store a pair of tokens to the storage and refresh the state
|
||||
Future<void> writeTokenPair(Tokens pair) async {
|
||||
final tokenRepo = ref.read(tokenStorageRepositoryProvider);
|
||||
log("Writing tokens");
|
||||
await tokenRepo.storeRefreshToken(pair.refreshToken);
|
||||
await tokenRepo.storeAccessToken(pair.accessToken);
|
||||
state = await AsyncValue.guard(() async {
|
||||
return state.value!.copyWith(
|
||||
accessToken: pair.accessToken,
|
||||
refreshToken: pair.refreshToken,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> checkTokens() async {
|
||||
final currentState = state.value;
|
||||
if (currentState == null ||
|
||||
currentState.accessToken == null ||
|
||||
currentState.refreshToken == null) {
|
||||
log("Trying to get tokens from the storage");
|
||||
state = await AsyncValue.guard(() async {
|
||||
final tokenRepo = ref.read(tokenStorageRepositoryProvider);
|
||||
return TokenState(
|
||||
accessToken: await tokenRepo.getAccessToken(),
|
||||
refreshToken: await tokenRepo.getRefreshToken(),
|
||||
);
|
||||
});
|
||||
}
|
||||
log("checking tokens");
|
||||
|
||||
final inMemAccessToken = state.value!.getAccessToken();
|
||||
// If we have a valid token in memory, nothing must be done
|
||||
// TODO: Store the expiration in memory, to make it cheaper to check
|
||||
if (verifyJWT(inMemAccessToken)) {
|
||||
return;
|
||||
} else {
|
||||
log("Access it not valid");
|
||||
final inMemRefreshToken = state.value!.getRefreshToken();
|
||||
if (verifyJWT(inMemRefreshToken)) {
|
||||
log("Trying to refresh");
|
||||
try {
|
||||
final refreshSessionGrpc = ref.read(refreshSessionGrpcProvider);
|
||||
log("Trying to refresh");
|
||||
final response = await refreshSessionGrpc.refreshSession(
|
||||
inMemRefreshToken,
|
||||
);
|
||||
await writeTokenPair(Tokens.fromProto(response.tokenPair));
|
||||
return;
|
||||
} catch (e) {
|
||||
log(e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
log("Refrsh it not valid");
|
||||
await resetTokens();
|
||||
ref.read(authorizationControllerProvider.notifier).logout();
|
||||
}
|
||||
|
||||
bool verifyJWT(String? token) {
|
||||
if (token == null || token.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
bool isExpired = JwtDecoder.isExpired(token);
|
||||
return !isExpired;
|
||||
} catch (_) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
42
lib/core/tokens/data/token_storage_repository.dart
Normal file
42
lib/core/tokens/data/token_storage_repository.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
const accessTokenKey = "access_token";
|
||||
const refreshTokenKey = "refresh_token";
|
||||
|
||||
final tokenStorageRepositoryProvider = Provider<TokenStorageRepository>((ref) {
|
||||
return TokenStorageRepository();
|
||||
});
|
||||
|
||||
// Secure storage backend for storing tokens in the browser
|
||||
class TokenStorageRepository {
|
||||
static const _storage = FlutterSecureStorage();
|
||||
TokenStorageRepository();
|
||||
|
||||
// Store the refresh token in the storage
|
||||
Future<void> storeRefreshToken(String token) async {
|
||||
await _storage.write(key: refreshTokenKey, value: token);
|
||||
}
|
||||
|
||||
// Store the access token in the storage
|
||||
Future<void> storeAccessToken(String token) async {
|
||||
await _storage.write(key: accessTokenKey, value: token);
|
||||
}
|
||||
|
||||
// Get the acccess token from the storage
|
||||
Future<String?> getAccessToken() async {
|
||||
final token = await _storage.read(key: accessTokenKey);
|
||||
return token;
|
||||
}
|
||||
|
||||
// Get the refresh token from the storage
|
||||
Future<String?> getRefreshToken() async {
|
||||
final token = await _storage.read(key: refreshTokenKey);
|
||||
return token;
|
||||
}
|
||||
|
||||
Future<void> clearStorage() async {
|
||||
await _storage.delete(key: refreshTokenKey);
|
||||
await _storage.delete(key: accessTokenKey);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user