Skip to content

Commit

Permalink
Merge pull request vania-dart#160 from javad-zobeidi/dev
Browse files Browse the repository at this point in the history
Add CSRF Token
javad-zobeidi authored Jan 15, 2025

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
2 parents 27855a7 + 615ab41 commit 4aeffcf
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';
@@ -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) &&
@@ -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);
}
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();
@@ -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.
///
@@ -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.
@@ -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.
@@ -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.
@@ -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);
}
}
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;
}
}
39 changes: 36 additions & 3 deletions lib/src/route/router.dart
Original file line number Diff line number Diff line change
@@ -3,6 +3,8 @@ import 'package:vania/src/route/route_data.dart';
import 'package:vania/src/websocket/web_socket_handler.dart';
import 'package:vania/vania.dart';

import 'middleware/csrf_middleware.dart';

class Router {
static final Router _singleton = Router._internal();
factory Router() => _singleton;
@@ -11,7 +13,7 @@ class Router {
String? _prefix;
String? _groupPrefix;
String? _groupDomain;
final List<Middleware> _groupMiddleware = [];
final List<Middleware> _groupMiddleware = [CsrfMiddleware()];

final List<RouteData> _routes = [];

@@ -23,7 +25,20 @@ class Router {
prefix.endsWith("/") ? prefix.substring(0, prefix.length - 1) : prefix;
}

/// Adds a route internally.
/// Internal method to add a route to the router. This method is used by the
/// route macros like [get], [post], [put], [patch], [delete], etc.
///
/// The [path] parameter is the path of the route. The [action] parameter is the
/// function that will be called when the route is matched. The [paramTypes]
/// parameter is a map of the parameter names to their types. The [regex]
/// parameter is a map of the parameter names to their regular expressions.
///
/// The [hasRequest] parameter is a boolean that indicates whether the route
/// action has a request parameter. If it is true then the route action will
/// receive a request object as a parameter.
///
/// The method returns the router object so that you can chain it with other
/// methods.
Router _addRouteInternal(
HttpRequestMethod method,
String path,
@@ -44,6 +59,17 @@ class Router {
return this;
}

/// Checks if the given input string is a closure that contains a
/// [Request] object as its first parameter.
///
/// The check is done by looking for the string 'Closure: (' and then
/// extracting the parameter names and checking if the first one is
/// 'Request'. If it is, then the method returns true. Otherwise, it
/// returns false.
///
/// The method is used by [_addRouteInternal] to determine if the route
/// action has a request parameter. If it does, then the route action
/// will receive a request object as a parameter.
bool _getRequestVar(String input) {
RegExp closureRegExp = RegExp(r'Closure: \(([^)]*)\) =>');
Match? closureMatch = closureRegExp.firstMatch(input);
@@ -250,7 +276,14 @@ class Router {
WebSocketHandler().websocketRoute(path, middleware: middleware));
}

/// Groups a set of routes under a common prefix, middleware, and/or domain.
/// Groups a set of routes under the same prefix, middleware, and domain settings.
///
/// The [callBack] function is executed within the context of the group, allowing
/// routes added inside to inherit the specified [prefix], [middleware], and [domain].
///
/// - [prefix]: An optional string to be added as a prefix to all routes within the group.
/// - [middleware]: A list of middleware to be applied to all routes within the group.
/// - [domain]: An optional domain that all routes within the group will respond to.
static void group(
Function callBack, {
String? prefix,
44 changes: 42 additions & 2 deletions lib/src/utils/functions.dart
Original file line number Diff line number Diff line change
@@ -1,20 +1,60 @@
import 'dart:math';

/// Sanitizes a route path by replacing multiple slashes with a single slash and
/// removing leading and trailing slashes.
String sanitizeRoutePath(String path) {
path = path.replaceAll(RegExp(r'/+'), '/');
return path.replaceAll(RegExp('^\\/+|\\/+\$'), '');
}

String randomString([int length = 32]) {
/// Generates a random string of a given [length] with the given character set.
///
/// The default length is 32. The default character set is all letters of the
/// alphabet, both lowercase and uppercase. If [numbers] or [special] is true,
/// the character set is extended to include numbers or special characters,
/// respectively. The generated string is a random permutation of the characters
/// in the character set.
String randomString({
int length = 32,
bool numbers = false,
bool special = false,
}) {
List<String> strList =
'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz'.split('');
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');

if (numbers) {
strList.addAll('1234567890'.split(''));
}

if (special) {
strList.addAll('!@#%^&*()_'.split(''));
}

strList.shuffle();
String chars = strList.join('');
Random rnd = Random();
return String.fromCharCodes(Iterable.generate(
length, (_) => chars.codeUnitAt(rnd.nextInt(chars.length))));
}

/// Generate a random number as a string of a given length
///
/// If T is int, it will be parsed as an integer and returned as a int
/// otherwise, it will be returned as a string
///
/// The generated numbers are all positive
///
/// The default length is 6
///
/// [length] is the length of the generated number
///
/// Returns a random number as a string of [length] length
///
/// Example:
///
/// var rand = randomInt(); // '123456'
/// var rand = randomInt(3); // '246'
/// var rand = randomInt<int>(3); // 246
T randomInt<T>([int length = 6]) {
List<String> strList = '1234567890'.split('');
strList.shuffle();
15 changes: 15 additions & 0 deletions lib/src/view_engine/processor_engine/csrf_processor.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import 'package:vania/src/http/session/session_manager.dart';
import 'package:vania/src/view_engine/processor_engine/abs_processor.dart';

class CsrfProcessor implements AbsProcessor {
@override
String parse(String content, [Map<String, dynamic>? context]) {
final csrfPattern = RegExp(
r"\{@\s*csrf\s*@\}",
dotAll: true,
);
return content.replaceAllMapped(csrfPattern, (match) {
return '<input type="hidden" name="_csrf" value="${SessionManager().csrfToken}">';
});
}
}
16 changes: 16 additions & 0 deletions lib/src/view_engine/processor_engine/csrf_token_processor.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import 'package:vania/src/http/session/session_manager.dart';
import 'package:vania/src/view_engine/processor_engine/abs_processor.dart';

class CsrfTokenProcessor implements AbsProcessor {
@override
String parse(String content, [Map<String, dynamic>? context]) {
final csrfMethodPattern = RegExp(
r"\{@\s*csrf_token\(\)\s*@\}",
dotAll: true,
);

return content.replaceAllMapped(csrfMethodPattern, (match) {
return SessionManager().csrfToken;
});
}
}
6 changes: 6 additions & 0 deletions lib/src/view_engine/template_engine.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import 'package:vania/src/view_engine/processor_engine/abs_processor.dart';
import 'package:vania/src/view_engine/processor_engine/variables_processor.dart';

import 'processor_engine/csrf_processor.dart';
import 'processor_engine/csrf_token_processor.dart';
import 'processor_engine/if_statement_processor.dart';
import 'processor_engine/extends_processor.dart';
import 'processor_engine/for_loop_processor.dart';
@@ -32,6 +34,8 @@ class TemplateEngine {
final ForLoopProcessor _forLoopProcessor = ForLoopProcessor();
final IncludeProcessor _includeProcessor = IncludeProcessor();
final ExtendsProcessor _extendsProcessor = ExtendsProcessor();
final CsrfProcessor _csrfProcessor = CsrfProcessor();
final CsrfTokenProcessor _csrfTokenProcessor = CsrfTokenProcessor();
final SectionProcessor _sectionProcessor = SectionProcessor();

String render(String template, [Map<String, dynamic>? data]) {
@@ -69,6 +73,8 @@ class TemplateEngine {
_switchCaseProcess,
_conditionalProcess,
_variablesProcess,
_csrfProcessor,
_csrfTokenProcessor,
]);

final renderedContent = pipeline.run(templateContent, data);

0 comments on commit 4aeffcf

Please sign in to comment.