Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions mobile/lib/core/network/api_client.dart
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'auth_interceptor.dart';

/// Single entry point for all HTTP calls, routed through api-gateway.
/// Never hit a microservice's own port directly from the app.
class ApiClient {
static const String baseUrl = 'http://localhost:8080';
late final Dio dio;

ApiClient() {
ApiClient(FlutterSecureStorage storage) {
dio = Dio(
BaseOptions(
baseUrl: baseUrl,
Expand All @@ -15,9 +17,10 @@ class ApiClient {
headers: {'Content-Type': 'application/json'},
),
);
dio.interceptors.add(AuthInterceptor(storage));
}

/// Test-only constructor: injects a (typically mocked) Dio instance
/// directly, bypassing real network configuration.
/// directly, bypassing real network configuration and the auth interceptor.
ApiClient.withDio(this.dio);
}
}
20 changes: 20 additions & 0 deletions mobile/lib/core/network/auth_interceptor.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

/// Attaches the stored access token to every outgoing request. Without this,
/// every call is rejected with 401 by api-gateway's JwtAuthenticationFilter
/// (except the public /api/auth/** routes).
class AuthInterceptor extends Interceptor {
final FlutterSecureStorage _storage;

AuthInterceptor(this._storage);

@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
final token = await _storage.read(key: 'access_token');
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
Comment on lines +13 to +18

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Unhandled storage read failure can hang requests indefinitely.

If _storage.read throws (platform exception, corrupted storage, permissions change), handler.next(options) is never called and the request stalls until the Dio timeout fires. Wrap the read in a try-catch so the request proceeds without a token — the gateway will return 401, which is the correct degraded behavior.

🛡️ Proposed fix: add error handling around storage read
   `@override`
   void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
-    final token = await _storage.read(key: 'access_token');
-    if (token != null) {
-      options.headers['Authorization'] = 'Bearer $token';
+    try {
+      final token = await _storage.read(key: 'access_token');
+      if (token != null) {
+        options.headers['Authorization'] = 'Bearer $token';
+      }
+    } catch (_) {
+      // If storage is unavailable, proceed without token; gateway will reject with 401.
     }
     handler.next(options);
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
final token = await _storage.read(key: 'access_token');
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
try {
final token = await _storage.read(key: 'access_token');
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
} catch (_) {
// If storage is unavailable, proceed without token; gateway will reject with 401.
}
handler.next(options);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mobile/lib/core/network/auth_interceptor.dart` around lines 13 - 18, Update
onRequest to catch failures from _storage.read, allowing the request to continue
without an Authorization header when token retrieval fails. Ensure
handler.next(options) is always called after the read attempt, while preserving
the existing header behavior for successfully retrieved non-null tokens.

}
}
46 changes: 39 additions & 7 deletions mobile/lib/features/auth/presentation/auth_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import '../../../core/network/api_client.dart';
import '../data/auth_repository.dart';
import '../domain/auth_models.dart';

final apiClientProvider = Provider((ref) => ApiClient());
final apiClientProvider = Provider((ref) => ApiClient(ref.watch(secureStorageProvider)));

final authRepositoryProvider = Provider((ref) {
return AuthRepository(ref.watch(apiClientProvider));
Expand All @@ -21,20 +21,46 @@ class AuthController extends StateNotifier<AuthResponse?> {

Future<AuthResponse> login(String email, String password) async {
final result = await _repository.login(email: email, password: password);
await _storage.write(key: 'access_token', value: result.accessToken);
await _storage.write(key: 'refresh_token', value: result.refreshToken);
await _storage.write(key: 'account_id', value: result.id);
await _persistSession(result);
state = result;
return result;
}

Future<AuthResponse> register(String email, String password) async {
final result = await _repository.register(email: email, password: password);
await _persistSession(result);
state = result;
return result;
}

Future<void> _persistSession(AuthResponse result) async {
await _storage.write(key: 'access_token', value: result.accessToken);
await _storage.write(key: 'refresh_token', value: result.refreshToken);
await _storage.write(key: 'account_id', value: result.id);
state = result;
return result;
await _storage.write(key: 'email', value: result.email);
await _storage.write(key: 'role', value: result.role);
}

/// Reconstructs the session from secure storage on app startup (page
/// reload, direct URL navigation, hot restart). Without this, the
/// in-memory-only state means every reload silently logs the user out
/// even though valid tokens are still on disk.
Future<void> restoreSession() async {
final accessToken = await _storage.read(key: 'access_token');
final refreshToken = await _storage.read(key: 'refresh_token');
final accountId = await _storage.read(key: 'account_id');
final email = await _storage.read(key: 'email');
final role = await _storage.read(key: 'role');

if (accessToken != null && refreshToken != null && accountId != null && email != null && role != null) {
state = AuthResponse(
id: accountId,
accessToken: accessToken,
refreshToken: refreshToken,
email: email,
role: role,
);
}
Comment on lines +44 to +63

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== files ==\n'
git ls-files 'mobile/lib/features/auth/presentation/auth_providers.dart' 'mobile/lib/main.dart'

printf '\n== outline auth_providers ==\n'
ast-grep outline mobile/lib/features/auth/presentation/auth_providers.dart --view expanded || true

printf '\n== outline main ==\n'
ast-grep outline mobile/lib/main.dart --view expanded || true

printf '\n== relevant lines auth_providers ==\n'
sed -n '1,220p' mobile/lib/features/auth/presentation/auth_providers.dart | cat -n

printf '\n== relevant lines main ==\n'
sed -n '1,160p' mobile/lib/main.dart | cat -n

Repository: ASSONDJI/afrilingua

Length of output: 5898


Guard restoreSession() against secure-storage failures
mobile/lib/main.dart awaits restoreSession() before runApp(), so any PlatformException from _storage.read(...) will abort startup and leave the app blank. Catch the reads here and fall back to a logged-out state instead of crashing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mobile/lib/features/auth/presentation/auth_providers.dart` around lines 44 -
63, Update Auth provider’s restoreSession() to catch secure-storage read
failures, including PlatformException, and reset or retain the logged-out state
instead of allowing the exception to propagate to startup. Keep successful token
reconstruction via AuthResponse unchanged, and use the existing logging
mechanism if available to record the failure.

}

Future<void> logout() async {
Expand All @@ -50,4 +76,10 @@ final authControllerProvider = StateNotifierProvider<AuthController, AuthRespons
/// Convenience accessor: the current session's accountId, or null if logged out.
final accountIdProvider = Provider<String?>((ref) {
return ref.watch(authControllerProvider)?.id;
});
});
/// Gate used at app startup: the app waits for this to complete before
/// building any real screen, so no route ever runs with a stale/null
/// session while restoreSession() is still in flight.
final sessionRestorationProvider = FutureProvider<void>((ref) async {
await ref.read(authControllerProvider.notifier).restoreSession();
});
20 changes: 16 additions & 4 deletions mobile/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,23 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/router/app_router.dart';
import 'core/theme/app_theme.dart';
import 'features/auth/presentation/auth_providers.dart';

void main() {
runApp(const ProviderScope(child: AfriLinguaApp()));
Future<void> main() async {
// Restaure la session AVANT de construire l'arbre de widgets, pour que
// go_router (via MaterialApp.router) evalue l'URL initiale du navigateur
// avec un accountIdProvider deja peuple. Ca evite a la fois le flash
// "login -> ecran demande" et le bug de parsing d'URL introduit par une
// precedente tentative avec un MaterialApp non-router imbrique.
final container = ProviderContainer();
await container.read(authControllerProvider.notifier).restoreSession();

runApp(
UncontrolledProviderScope(
container: container,
child: const AfriLinguaApp(),
),
);
}

class AfriLinguaApp extends ConsumerWidget {
Expand All @@ -13,7 +27,6 @@ class AfriLinguaApp extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(routerProvider);

return MaterialApp.router(
title: 'AfriLingua',
debugShowCheckedModeBanner: false,
Expand All @@ -22,4 +35,3 @@ class AfriLinguaApp extends ConsumerWidget {
);
}
}

18 changes: 18 additions & 0 deletions services/api-gateway/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
<properties>
<java.version>21</java.version>
<spring-cloud.version>2023.0.3</spring-cloud.version>
<jjwt.version>0.12.6</jjwt.version>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check Maven Central for the latest JJWT 0.12.x release
curl -s "https://repo1.maven.org/maven2/io/jsonwebtoken/jjwt-api/maven-metadata.xml" | grep -oP '<version>0\.12\.\d+</version>' | sort -V | tail -5

# Check GitHub advisories for JJWT
gh api graphql -f query='
{
  securityVulnerabilities(first: 5, ecosystem: MAVEN, package: "io.jsonwebtoken:jjwt-api") {
    nodes {
      advisory { summary severity }
      vulnerableVersionRange
      firstPatchedVersion { identifier }
    }
  }
}'

Repository: ASSONDJI/afrilingua

Length of output: 336


🏁 Script executed:

#!/bin/bash
set -euo pipefail

for artifact in jjwt-api jjwt-impl jjwt-jackson; do
  echo "== $artifact =="
  curl -fsS "https://repo1.maven.org/maven2/io/jsonwebtoken/${artifact}/maven-metadata.xml" \
    | grep -oP '<version>0\.12\.\d+</version>' \
    | sort -V \
    | tail -5
done

Repository: ASSONDJI/afrilingua

Length of output: 597


🏁 Script executed:

#!/bin/bash
set -euo pipefail
sed -n '1,120p' services/api-gateway/pom.xml | cat -n

Repository: ASSONDJI/afrilingua

Length of output: 3634


Bump JJWT to 0.12.70.12.6 is behind the current 0.12.x patch; update the version property and keep jjwt-api, jjwt-impl, and jjwt-jackson aligned.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/api-gateway/pom.xml` at line 23, Update the jjwt.version property
from 0.12.6 to 0.12.7 in the Maven configuration, ensuring jjwt-api, jjwt-impl,
and jjwt-jackson continue resolving the same aligned version through this
property.

</properties>

<dependencies>
Expand All @@ -35,6 +36,23 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;

@SpringBootApplication
@ConfigurationPropertiesScan
public class ApiGatewayApplication {
public static void main(String[] args) {
SpringApplication.run(ApiGatewayApplication.class, args);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package cm.afrilingua.gateway.config;

import org.springframework.boot.context.properties.ConfigurationProperties;

/** Mirrors auth-service's JwtProperties -- must share the same secret-key so
* tokens signed by auth-service can be verified here. */
@ConfigurationProperties(prefix = "application.security.jwt")
public record JwtProperties(String secretKey) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package cm.afrilingua.gateway.security;

import cm.afrilingua.gateway.config.JwtProperties;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.List;

/**
* Validates the JWT on every request except the public auth routes, then
* strips the client-supplied Authorization header and replaces it with a
* trusted X-User-Id header before forwarding downstream. Internal services
* never need to know about JWT at all -- they just trust X-User-Id, because
* this filter is the only entry point into the system (services are not
* exposed directly, only through this gateway).
*
* This is the single point of authentication for the whole platform: no
* other service validates tokens. Keeping it here (rather than duplicating
* JWT validation in every microservice) is what makes X-User-Id trustworthy
* downstream -- as long as nothing bypasses the gateway in production.
*/
@Component
public class JwtAuthenticationFilter implements GlobalFilter, Ordered {

private static final List<String> PUBLIC_PATH_PREFIXES = List.of("/api/auth/");

private final SecretKey signingKey;

public JwtAuthenticationFilter(JwtProperties jwtProperties) {
this.signingKey = Keys.hmacShaKeyFor(jwtProperties.secretKey().getBytes(StandardCharsets.UTF_8));
}

@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getURI().getPath();

if (isPublicPath(path)) {
return chain.filter(exchange);
}

String authHeader = exchange.getRequest().getHeaders().getFirst("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
return unauthorized(exchange, "Missing or malformed Authorization header");
}

String token = authHeader.substring("Bearer ".length());

try {
Claims claims = Jwts.parser()
.verifyWith(signingKey)
.build()
.parseSignedClaims(token)
.getPayload();

String userId = claims.getSubject();

ServerHttpRequest mutatedRequest = exchange.getRequest().mutate()
.headers(headers -> {
headers.remove("Authorization");
headers.remove("X-User-Id"); // never trust a client-supplied value
headers.set("X-User-Id", userId);
})
.build();

return chain.filter(exchange.mutate().request(mutatedRequest).build());
} catch (JwtException | IllegalArgumentException e) {
return unauthorized(exchange, "Invalid or expired token");
}
}
Comment on lines +61 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Unvalidated null subject propagates into X-User-Id.

claims.getSubject() (line 68) isn't checked for null/blank before being set as X-User-Id (line 74). If a token is ever parsed without a sub claim (malformed/legacy token, misconfigured issuer), downstream services would receive either a missing header or a header literally set to "null", and QuestionService.extractUserId() (which does UUID.fromString(userId)) would throw on a non-UUID value rather than the request being rejected here at the gateway boundary.

🛡️ Suggested defensive check
             String userId = claims.getSubject();
+            if (userId == null || userId.isBlank()) {
+                return unauthorized(exchange, "Invalid or expired token");
+            }

JWT parsing itself (Jwts.parser().verifyWith(signingKey).build().parseSignedClaims(token)) correctly follows the jjwt 0.12.x API.

Note: the static analysis hint about a "randomly-generated IV" on line 91 is a false positive — that line only builds a JSON error string, no cryptographic/CBC operation is involved.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
Claims claims = Jwts.parser()
.verifyWith(signingKey)
.build()
.parseSignedClaims(token)
.getPayload();
String userId = claims.getSubject();
ServerHttpRequest mutatedRequest = exchange.getRequest().mutate()
.headers(headers -> {
headers.remove("Authorization");
headers.remove("X-User-Id"); // never trust a client-supplied value
headers.set("X-User-Id", userId);
})
.build();
return chain.filter(exchange.mutate().request(mutatedRequest).build());
} catch (JwtException | IllegalArgumentException e) {
return unauthorized(exchange, "Invalid or expired token");
}
}
try {
Claims claims = Jwts.parser()
.verifyWith(signingKey)
.build()
.parseSignedClaims(token)
.getPayload();
String userId = claims.getSubject();
if (userId == null || userId.isBlank()) {
return unauthorized(exchange, "Invalid or expired token");
}
ServerHttpRequest mutatedRequest = exchange.getRequest().mutate()
.headers(headers -> {
headers.remove("Authorization");
headers.remove("X-User-Id"); // never trust a client-supplied value
headers.set("X-User-Id", userId);
})
.build();
return chain.filter(exchange.mutate().request(mutatedRequest).build());
} catch (JwtException | IllegalArgumentException e) {
return unauthorized(exchange, "Invalid or expired token");
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@services/api-gateway/src/main/java/cm/afrilingua/gateway/security/JwtAuthenticationFilter.java`
around lines 61 - 82, Validate the subject returned by claims.getSubject() in
JwtAuthenticationFilter before mutating the request: reject null or blank values
through the existing unauthorized flow instead of setting X-User-Id. Preserve
the current header replacement and valid-token behavior for nonblank subjects.


private boolean isPublicPath(String path) {
return PUBLIC_PATH_PREFIXES.stream().anyMatch(path::startsWith);
}

private Mono<Void> unauthorized(ServerWebExchange exchange, String message) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
response.getHeaders().add("Content-Type", "application/json");
byte[] body = ("{\"message\":\"" + message + "\"}").getBytes(StandardCharsets.UTF_8);
return response.writeWith(Mono.just(response.bufferFactory().wrap(body)));
}

@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE;
}
}
6 changes: 5 additions & 1 deletion services/api-gateway/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,8 @@ management:
endpoints:
web:
exposure:
include: health,gateway
include: health,gateway
application:
security:
jwt:
secret-key: ${JWT_SECRET_KEY:404E635266556A586E3272357538782F413F4428472B4B6250645367566B59}
Comment on lines +73 to +77

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Hardcoded JWT secret default checked into source control.

${JWT_SECRET_KEY:404E635266556A586E3272357538782F413F4428472B4B6250645367566B59} bakes a fixed fallback secret directly into the repo. Anyone with source access can read this value and forge valid JWTs for any user if JWT_SECRET_KEY isn't set in an environment (misconfigured deploy, forgotten env var, local/staging environment mistakenly treated as trusted). Given JwtAuthenticationFilter is documented as "the single point of authentication for the whole platform," a leaked/guessable static secret defeats the entire auth model.

Fail fast instead of silently falling back to a checked-in secret:

🔒 Suggested fix
 application:
   security:
     jwt:
-      secret-key: ${JWT_SECRET_KEY:404E635266556A586E3272357538782F413F4428472B4B6250645367566B59}
+      secret-key: ${JWT_SECRET_KEY}

Note: this must be coordinated with auth-service's equivalent property, which per the JwtProperties comment must share the exact same secret.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
include: health,gateway
application:
security:
jwt:
secret-key: ${JWT_SECRET_KEY:404E635266556A586E3272357538782F413F4428472B4B6250645367566B59}
include: health,gateway
application:
security:
jwt:
secret-key: ${JWT_SECRET_KEY}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/api-gateway/src/main/resources/application.yml` around lines 73 -
77, Remove the hardcoded fallback from the JWT secret-key configuration and
require JWT_SECRET_KEY to be explicitly provided, failing application startup
when it is absent. Apply the same required-secret configuration to
auth-service’s equivalent JwtProperties setting, preserving the exact shared
secret contract between both services.

Loading
Loading