Skip to content

Commit

Permalink
Merge pull request #160 from javad-zobeidi/dev
Browse files Browse the repository at this point in the history
Add CSRF Token
  • Loading branch information
javad-zobeidi authored Jan 15, 2025
2 parents 27855a7 + 615ab41 commit 4aeffcf
Show file tree
Hide file tree
Showing 10 changed files with 316 additions and 35 deletions.
11 changes: 11 additions & 0 deletions lib/src/exception/page_expired_exception.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import 'package:vania/src/http/response/response.dart';

import 'base_http_exception.dart';

class PageExpiredException extends BaseHttpResponseException {
const PageExpiredException({
super.message = '<center><h1>Page Expired (419)</h1></center>',
super.code = 419,
super.responseType = ResponseType.html,
});
}
11 changes: 8 additions & 3 deletions lib/src/http/request/request_handler.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import 'dart:io';
import 'dart:math';
import 'package:vania/src/config/http_cors.dart';
import 'package:vania/src/exception/internal_server_error.dart';
import 'package:vania/src/exception/invalid_argument_exception.dart';
import 'package:vania/src/exception/page_expired_exception.dart';
import 'package:vania/src/exception/not_found_exception.dart';
import 'package:vania/src/exception/unauthenticated.dart';
import 'package:vania/src/http/controller/controller_handler.dart';
Expand All @@ -24,9 +24,8 @@ import '../session/session_manager.dart';
/// Throws:
/// - [BaseHttpResponseException] if there is an issue with the HTTP response.
/// - [InvalidArgumentException] if an invalid argument is encountered.
Future httpRequestHandler(HttpRequest req) async {
SessionManager().sessionStart(req, req.response);
await SessionManager().sessionStart(req, req.response);

/// Check the incoming request is web socket or not
if (env<bool>('APP_WEBSOCKET', false) &&
Expand Down Expand Up @@ -69,6 +68,12 @@ Future httpRequestHandler(HttpRequest req) async {
}
}

if (error is PageExpiredException && isHtml) {
if (File('lib/view/template/errors/419.html').existsSync()) {
return view('errors/419').makeResponse(req.response);
}
}

if (error is Unauthenticated && isHtml) {
return Response.redirect(error.message).makeResponse(req.response);
}
Expand Down
121 changes: 94 additions & 27 deletions lib/src/http/session/session_manager.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:vania/src/utils/functions.dart';
import 'package:vania/vania.dart';
import 'session_file_store .dart';
import 'session_file_store.dart';

class SessionManager {
static final SessionManager _instance = SessionManager._internal();
Expand All @@ -11,10 +11,15 @@ class SessionManager {

HttpRequest? _request;

final Duration _sessionLifeTime =
Duration(seconds: env<int>('SESSION_LIFETIME', 3600));
String sessionKey = '${env<String>('APP_NAME', 'Vania')}_session';

String _csrfToken = '';

final Random _random = Random.secure();
String get csrfToken => _csrfToken;

final Duration _sessionLifeTime =
Duration(seconds: env<int>('SESSION_LIFETIME', 9000));
bool secureSession = env<bool>('SECURE_SESSION', true);

/// Generates a new session ID.
///
Expand All @@ -24,18 +29,68 @@ class SessionManager {
/// Returns:
/// A base64 URL encoded string representing the session ID.
String _generateSessionId() {
final keyBytes = List<int>.generate(64, (_) => _random.nextInt(256));
return base64Url.encode(keyBytes);
final keyBytes = randomString(length: 64, numbers: true);
return base64Url.encode(utf8.encode(keyBytes));
}

/// Starts a new session or retrieves an existing session ID from the request.
/// Creates a CSRF token and sets it as a secure cookie in the HTTP response.
///
/// This function checks if an 'XSRF-TOKEN' cookie is already present in the
/// request. If not, it generates a new random CSRF token along with an
/// initialization vector (IV), and sets them as cookies in the response. The
/// token and IV are also stored in the session for future validation.
///
/// Parameters:
/// - `request`: The incoming HTTP request containing the cookies.
/// - `response`: The HTTP response where the CSRF token cookie will be added.
///
/// The generated CSRF token is URL-safe and securely stored in the session with
/// the specified session lifetime. The cookie is configured with security
/// attributes such as domain, expiration, SameSite policy, and HTTP-only flag
/// to mitigate CSRF attacks.
Future<void> createXsrfToken(
HttpRequest request,
HttpResponse response,
) async {
final cookie = request.cookies.firstWhere(
(c) => c.name == 'XSRF-TOKEN',
orElse: () => Cookie('XSRF-TOKEN', ''),
);
String token = cookie.value;
String? storedToken = await getSession<String?>('x_csrf_token');

if (token.isEmpty || storedToken == null) {
await generateNewToken(response);
}
}

Future<void> generateNewToken(HttpResponse response) async {
String token = randomString(length: 40, numbers: true);
String iv = randomString(length: 32, numbers: true);
Hash().setHashKey(iv);

await setSession('x_csrf_token_iv', iv);
await setSession('x_csrf_token', token);
_csrfToken = token;
token = Hash().make(token);
response.cookies.add(
Cookie('XSRF-TOKEN', base64Url.encode(utf8.encode(token)))
..expires = DateTime.now().add(Duration(seconds: 9000))
..sameSite = SameSite.lax
..secure = secureSession
..path = '/'
..httpOnly = true,
);
}

/// Starts a new session or retrieves an existing session from the request.
///
/// This method initializes a session for the given HTTP request and response.
/// If a 'SESSION_ID' cookie is already present in the request, its value is
/// used as the session ID. Otherwise, a new session ID is generated and set
/// If a sessionKey cookie is already present in the request, its value is
/// used as the session . Otherwise, a new session is generated and set
/// as a cookie in the response.
///
/// The session ID is stored in a cookie with properties configured for HTTP
/// The session is stored in a cookie with properties configured for HTTP
/// only access, insecure transmission (consider changing to true for secure
/// transmission), a path set to '/', and an expiration set to the session
/// timeout duration.
Expand All @@ -45,40 +100,53 @@ class SessionManager {
/// - [response]: The HTTP response where the session cookie will be added.
///
/// Returns:
/// A string representing the session ID.
String sessionStart(HttpRequest request, HttpResponse response) {
/// A string representing the session.
Future<void> sessionStart(
HttpRequest request,
HttpResponse response,
) async {
_request = null;

_request ??= request;

final cookie = request.cookies.firstWhere(
(c) => c.name == 'SESSION_ID',
orElse: () => Cookie('SESSION_ID', _generateSessionId()),
(c) => c.name == sessionKey,
orElse: () => Cookie(sessionKey, _generateSessionId()),
);
String sessionId = cookie.value;

response.cookies.add(
Cookie('SESSION_ID', sessionId)
Cookie(sessionKey, sessionId)
..httpOnly = true
..secure = false
..secure = secureSession
..path = '/'
..sameSite = SameSite.lax
..expires = DateTime.now().add(_sessionLifeTime),
);

return sessionId;
_request?.cookies.add(
Cookie(sessionKey, sessionId)
..httpOnly = true
..secure = secureSession
..path = '/'
..sameSite = SameSite.lax
..expires = DateTime.now().add(_sessionLifeTime),
);

_csrfToken = await getSession<String?>('x_csrf_token') ?? '';
await createXsrfToken(request, response);
}

String? getSessionId() {
final cookie = _request?.cookies.firstWhere(
(c) => c.name == 'SESSION_ID',
orElse: () => Cookie('SESSION_ID', ''),
(c) => c.name == sessionKey,
orElse: () => Cookie(sessionKey, ''),
);
return cookie?.value;
}

/// Retrieves all session data associated with the current session ID.
///
/// This function checks if there is an active session by retrieving the
/// session ID. If a session ID is found, it verifies the existence and
/// If a session is found, it verifies the existence and
/// validity of the session. If the session exists, it retrieves and returns
/// the session data as a map. If the session does not exist or is invalid,
/// it returns null.
Expand Down Expand Up @@ -124,7 +192,7 @@ class SessionManager {

/// Stores a value in the session data associated with the current session ID.
///
/// If a session ID is found, it verifies the existence and validity of the session.
/// If a session is found, it verifies the existence and validity of the session.
/// If the session exists, it updates the session data by adding the given key-value pair,
/// and saves the updated session data. If the session does not exist or is invalid,
/// it does not store the value.
Expand All @@ -141,20 +209,19 @@ class SessionManager {

/// Deletes a specific key from the current session data.
///
/// If a session ID is found, it verifies the existence and validity of the session.
/// If a session is found, it verifies the existence and validity of the session.
/// If the session exists, it removes the given key from the session data, and saves the
/// updated session data. If the session does not exist or is invalid, it does not delete
/// the key.
///
/// Parameters:
/// - [key]: The key to be deleted from the session data.
Future<void> deleteSession(String key) async {
final sessionId = getSessionId();
final String? sessionId = getSessionId();
if (sessionId != null) {
Map<String, dynamic> session =
await SessionFileStore().retrieveSession(sessionId) ?? {};
session.remove(key);
print(session);
await SessionFileStore().storeSession(sessionId, session);
}
}
Expand Down
88 changes: 88 additions & 0 deletions lib/src/route/middleware/csrf_middleware.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import 'dart:convert';

import 'package:vania/src/exception/page_expired_exception.dart';
import 'package:vania/vania.dart';

import 'dart:async';

class CsrfMiddleware extends Middleware {
/// This middleware is used to verify the CSRF token in the request.
///
/// The middleware checks if the request method is GET or HEAD, if not then it
/// checks if the request URI is in the list of excluded paths from the CSRF
/// validation.
///
/// If the request URI is not in the excluded list, then it checks if the
/// _csrf or _token input field is present in the request, if not then it
/// throws a PageExpiredException.
///
/// If the token is present, then it verifies the token with the stored token
/// in the session, if the verification fails then it throws a
/// PageExpiredException.
///
@override
Future<void> handle(Request req) async {
if (req.method!.toLowerCase() != 'get' &&
req.method!.toLowerCase() != 'head') {
List<String> csrfExcept = ['api/*'];
csrfExcept.addAll(Config().get('csrf_except') ?? []);
if (!_isUrlExcluded(req.uri.path, csrfExcept)) {
final csrfToken = _fixBase64Padding(req.cookie('XSRF-TOKEN'));
String? token = req.input('_csrf');
token ??= req.input('_token');
token ??= req.header('X-CSRF-TOKEN');

if (token == null) {
throw PageExpiredException();
}

String storedToken = await getSession<String?>('x_csrf_token') ?? '';
if (storedToken != token) {
throw PageExpiredException();
}
String iv = await getSession<String?>('x_csrf_token_iv') ?? '';
Hash().setHashKey(iv);
if (!Hash().verify(token, csrfToken)) {
throw PageExpiredException();
}
}
}
}

String _fixBase64Padding(String value) {
while (value.length % 4 != 0) {
value += '=';
}
return utf8.decode(base64Url.decode(value));
}

/// Check if the given path is excluded from CSRF validation by checking if it
/// matches any of the patterns in the given list.
///
/// The list of patterns can contain simple strings or strings with a wildcard
/// at the end (e.g. 'api/*'). If the path matches a pattern with a wildcard
/// then it is considered excluded.
///
/// The path is considered excluded if it starts with the pattern without the
/// wildcard or if it matches the regular expression created by replacing the
/// wildcard with '.*'.
///
/// For example, if the pattern is 'api/*' then the path will be considered
/// excluded if it starts with '/api/' or if it matches the regular expression
/// '^/api/.*$'.
///
bool _isUrlExcluded(String path, List<String> csrfExcept) {
for (var pattern in csrfExcept) {
if (pattern.contains('/*')) {
final regexPattern = '^/${pattern.replaceAll('/*', '/.*')}\$';
final regex = RegExp(regexPattern);
if (regex.hasMatch(path)) {
return true;
}
} else if (path.startsWith('/$pattern')) {
return true;
}
}
return false;
}
}
Loading

0 comments on commit 4aeffcf

Please sign in to comment.