actix_plus_auth/lib.rs
1//! Overview
2//! ========
3//! One way to design a web backend is to split it into three parts: the API layer (for reusable actions used in the presentation layer), data layer (for interacting with the database), and presentation layer (for handling requests). This crate aims to provide the API layer for authentication. Example routes are provided in the examples folder, so you can get the presentation layer as well, although you are almost certainly going to want to make some changes so it is not included in the crate by default.
4//!
5//! Features
6//! ========
7//! - Easy: Makes it easy to add authentication to your web application by providing structure and doing the hard parts for you
8//! - Flexible: not opinionated on what your URL structure, UI, or database looks like
9//! - Fast: fully async, and does not require a database query unless logging in or registering (e.g. normal authenticated requests verify without a database query)
10//! - Stateless: no need for server pinning with this library, only cryptography is used to verify sessions, so it's fine if a user sporadically switches backend servers
11//!
12//! Usage
13//! =====
14//! Roughly speaking, there are three steps in using this library: create the data layer, integrate with Actix-Web, make the routes, and make the frontend. You can view a complete example [here](https://github.com/john01dav/actix-plus/blob/master/actix-plus-auth/examples/basic_use_in_memory_db.rs).
15//!
16//! Create the Data Layer
17//! ---------------------
18//! To create a data layer, you must create a Rust struct to hold the information that you want to associate with each user, except the password hash which is stored separately. You must store the email in this struct. An example struct follows:
19//! ```rust,ignore
20//! // !!!!!WARNING: anything in your account type is visible to users as it is encoded as a JWT!!!!!
21//! #[derive(Serialize, Deserialize, Debug)]
22//! pub struct ExampleAccount {
23//! pub username: String, //example of custom data to include with one's account type
24//! pub email: String,
25//! }
26//!
27//! impl Account for ExampleAccount {
28//! fn email(&self) -> &str {
29//! &self.email
30//! }
31//! }
32//! ```
33//! As you can see, after the struct is created, the `Account` trait is implemented to allow actix-plus-auth to access the email, and to signal that this struct represents an account. The `Account` trait, due to [JWT](https://jwt.io/) (Json Web Token) serialization and deserialization, requires that the serde `Serialize` and `DeserializeOwned` traits are implemented. To implement `DeserializeOwned` simply have a struct that owns all of its types (e.g. no reference members) and then derive `Deserialize`. Additionally, the `Account` trait requires the `Debug` trait. Of course, you can implement other traits as you see fit.
34//!
35//! **Note that anything in your account type is visible to users as it is encoded as a JWT for session storage. DO NOT PUT PRIVATE INFORMATION IN THE ACCOUNT TYPE.**
36//!
37//! Once the account struct is created, a data provider struct is needed. A data provider struct implements the operations that fetch and store this account type from a database. To create a data provider struct, simply implement the `DataProvider` trait:
38//! ```rust,ignore
39//! #[async_trait]
40//! pub trait DataProvider: Clone {
41//! type AccountType: Account;
42//!
43//! ///Adds a new account to the database, then returns that account back. You may need to clone the account when implementing this function. If another account exists with this email, then the function should return some sort of error.
44//! async fn insert_account(
45//! &self,
46//! account: Self::AccountType,
47//! password_hash: String,
48//! ) -> ResponseResult<Self::AccountType>;
49//!
50//! /// Fetches the account with the given email from the database, case-insensitively. Note that a lowercase email should be passed to this function, but the matching email as stored in the database may be in any case.
51//! async fn fetch_account(
52//! &self,
53//! email: &str,
54//! ) -> ResponseResult<Option<(Self::AccountType, String)>>;
55//! }
56//! ```
57//! Note that the [async_trait](https://crates.io/crates/async-trait) crate is used to facilitate async functions in this trait, and you must use it when implementing this trait as well. Also, note that the `insert_account` and `fetch_account` functions take `&self` and not `&mut self`, and that `DataProvider` requires `Clone`. The model in this library is that a single data store is shared across many instances of your data provider, and references to those instances, like a normal database connection pool (e.g. as in [sqlx](https://crates.io/crates/sqlx)). When your data provider is cloned, make sure to internally reference the same data such that if a change is made on the clone it can be read back from the original (or other clones) and visa-versa. The best way to implement this is to use a database library (such as [sqlx](https://crates.io/crates/sqlx)) that already works in this way.
58//!
59//! Congratulations! You have now created the data layer, which is 95% of the work to use this library.
60//!
61//! An example data provider is not shown here as it will vary wildly between different database backends.
62//!
63//! Integrate with Actix-Web
64//! ------------------------
65//! Once your data layer is created, you can move on to integrating with Actix Web. To do this, create an instance of `actix_plus_auth::AuthenticationProvider<T: DataProvider>` via the `new` function. To do this, you'll need a secret and an instance of your data provider:
66//! ```rust,ignore
67//! let auth = AuthenticationProvider::new(
68//! MyDataProvider::new(),
69//! "some secret, you should use a real one"
70//! .as_bytes()
71//! .iter()
72//! .map(|u| *u)
73//! .collect(),
74//! );
75//! ```
76//! The secret is used to verify JWTs, so it should be both secret (if it is leaked then users can forge a JWT for any account on your service) and unchanging (if it changes, existing sessions will be invalidated). If it is leaked, you should change it, and review your logs for any possible abuse. `AuthenticationProvider` is generic over a provided `DataProvider`-implementing struct, and it will infer the account type from your `DataProvider` implementation, also generically.
77//!
78//! Once you have an `AuthenticationProvider`, you simply need to make it available to each route. This is done via [Actix Web's state system](https://actix.rs/docs/application/#state):
79//! ```rust,ignore
80//! HttpServer::new(move || { //move your auth variable into the closure
81//! App::new()
82//! .data(auth.clone()) //clone the closure's copy for each Actix Web worker, this is why clones of a data provider must refer to the same data even when cloned
83//! })
84//! ```
85//!
86//! Lastly, as this system relies on cookies with the secure flag set, you must enable TLS/HTTPS for it to work. A simple self-signed certificate is sufficient for development. Refer to [Actix Web documentation](https://actix.rs/docs/http2/) for details on enabling TLS.
87//!
88//! Make the Routes
89//! ---------------
90//! Once an `AuthenticationProvider` is registered with Actix Web, routes for login, registration, and logout must be created. `AuthenticationProvider` provides functions that facilitate most of the heavy lifting for these operations, except for logout which is to be implemented entirely by the user.
91//!
92//! ### Register
93//! ```rust,ignore
94//! pub async fn register(
95//! &self,
96//! account: DataProviderImpl::AccountType,
97//! password: &str,
98//! ) -> ResponseResult<RegistrationOutcome<DataProviderImpl::AccountType>>
99//! ```
100//! Above is the signature of the registration function. To put it simply, it accepts a reference to the authentication provider, the password to register with, and an instance of your account type. It then returns a `ResponseResult` (from the actix-plus-error crate, used to propagate internal server errors such as those from the user-provided data layer) with a `RegistrationOutcome` instance which has three variants:
101//! ```rust,ignore
102//! ///The non-error outcomes of registration. Error outcomes are used when a genuine error takes place — e.g. the database is not reachable (represented by the functions on your DataProvider implementation returning an error).
103//! pub enum RegistrationOutcome<AccountType: Account> {
104//! ///The account is now in the database, and is given here.
105//! Successful(AccountType),
106//! ///The provided email is not a valid email
107//! InvalidEmail,
108//! //The provided email is already taken
109//! EmailTaken,
110//! }
111//! ```
112//!
113//! ### Login
114//! ```rust,ignore
115//! pub async fn login(
116//! &self,
117//! email: &str,
118//! password: &str,
119//! ) -> ResponseResult<LoginOutcome<DataProviderImpl::AccountType>>
120//! ```
121//! Above is the signature of the login function. To put it simply, it accepts a reference to to the authentication provider, an email, and a username (as provided by the user) and returns a `ResponseResult` (from the actix-plus-error crate, used to propagate internal server errors such as those from the user-provided data layer) with a `LoginOutcome` instance which has two variants:
122//! ```rust,ignore
123//! ///The non-error outcomes of logging in. Error outcomes are used when a genuine error takes place — e.g. the database is not reachable (represented by the functions on your DataProvider implementation returning an error).
124//! pub enum LoginOutcome<AccountType> {
125//! ///The credentials were correct, so the account and a cookie that should be set in the response to the login route are provided.
126//! Successful(AccountType, Cookie<'static>),
127//! //The provided credentials do not correspond to a valid account.
128//! InvalidEmailOrPassword,
129//! }
130//! ```
131//! If `InvalidEmailOrPassword` is returned then this information should be passed in your response in whatever way you see fit. If `Successful` is returned, then the provided cookie should be set on your HTTP response. Additionally, in the successful scenario, you may set additional cookies to share data with your frontend. Note that, following best practices for cookie-based token storage, the token cookie is HTTP only, so Javascript code can't access it. See [here](https://github.com/john01dav/actix-plus/blob/master/actix-plus-auth/examples/basic_use_in_memory_db.rs) for an example.
132//!
133//! ### Logout
134//! To logout, simply delete the `actix-plus-auth-token` cookie, along with any other cookies that you may have set in your login. You do not need to call into the library to delete a session or anything, as the library is stateless:
135//! ```rust,ignore
136//! #[post("/logout")]
137//! async fn logout(request: HttpRequest) -> Response {
138//! let mut response = HttpResponse::Ok();
139//! if let Some(mut session_cookie) = request.cookie("actix-plus-auth-token") { //you must delete this cookie
140//! session_cookie.set_path("/"); //this is needed to ensure that the cookie deletion goes through
141//! session_cookie.set_secure(true); //this is needed to ensure that the cookie deletion goes through
142//! response.del_cookie(&session_cookie);
143//! }
144//! if let Some(mut username_cookie) = request.cookie("username") { //delete optional user-added cookie
145//! username_cookie.set_path("/");
146//! username_cookie.set_secure(true);
147//! response.del_cookie(&username_cookie);
148//! }
149//! Ok(response.await?)
150//! }
151//! ```
152//!
153//! ### Authenticating in Other Routes
154//! In another route, you can simply call `current_user(req: &HttpRequest)` on `AuthenticationProvider` to get the current user. This function returns `Err(ResponseError)` that encodes a 401 Not Authorized (via the actix-plus-error crate), so if you use the same crate you can simply propagate with `?` to keep code concise:
155//! ```rust,ignore
156//! #[get("/private_page")]
157//! async fn private_page(request: HttpRequest, auth: Data<ExampleAuthProvider>) -> Response {
158//! let account = auth.current_user(&request)?;
159//! Ok(HttpResponse::Ok() //you can do anything here, including more traditional JSON/REST/etc. routes
160//! .body(format!("Hello {}", account.username))
161//! .await?)
162//! }
163//! ```
164//!
165//! Make the Frontend
166//! -----------------
167//! When making the frontend, there are some considerations to ensure that cookies are included with HTTP requests. Specifically, credentials should be set to 'same-origin' (both for requests to login, logout, and registration; and for general authenticated requests):
168//! ```javascript
169//! let response = await fetch('/login', {
170//! method: 'POST',
171//! credentials: 'same-origin',
172//! headers: {
173//! 'Content-Type': 'application/json'
174//! },
175//! body: JSON.stringify({
176//! email: document.getElementById('email').value,
177//! password: document.getElementById('password').value,
178//! })
179//! });
180//! ```
181//!
182//! Todo
183//! ====
184//! - Email Verification
185//! - SQLx example
186use actix_plus_error::{ResponseError, ResponseResult};
187use actix_plus_utils::current_unix_time_secs;
188use actix_web::cookie::{Cookie, CookieBuilder, SameSite};
189use actix_web::http::StatusCode;
190use actix_web::{HttpMessage, HttpRequest};
191use argon2::password_hash::SaltString;
192use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
193pub use async_trait::async_trait;
194use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
195use once_cell::sync::Lazy;
196use rand::rngs::OsRng;
197use regex::Regex;
198use serde::de::DeserializeOwned;
199use serde::{Deserialize, Serialize};
200use std::fmt::Debug;
201
202///A struct that implements this trait represents the data that is stored with each account in the authentication system. The only mandatory data to st ore in an account is the email that the account is associated with. The password should not be stored here. Your account object's data and the password, taken together, are usually one row in a SQL database (although other database types can be used as this library does not interact with the database), where the email is the primary key.
203///**Note that this data is viewable to the user as it is stored in an unencrypted (but signed) json web token!**
204pub trait Account: Serialize + DeserializeOwned + Debug + 'static {
205 ///Gets the email associated with this account.
206 fn email(&self) -> &str;
207}
208
209///A struct that implements this trait provides the functions that Actix+Auth needs to interact with your database (or flatfile, volatile storage in ram, whatever you want) to implement authentication. Although it is not strictly required to work at small scale, it is **strongly** recommended that the email of each account be able to be looked up quickly in a case-insensitive manner. With a SQL database, this can be accomplished by adding an index on the lowercase of the email. The email is also the primary key.
210///**Note that when this struct is cloned it should refer to the same datastore. This struct is cloned like a database pool is in normal Actix.**
211#[async_trait]
212pub trait DataProvider: Clone {
213 type AccountType: Account;
214
215 ///Adds a new account to the database, then returns that account back. You may need to clone the account when implementing this function. If another account exists with this email, then the function should return some sort of error.
216 async fn insert_account(
217 &self,
218 account: Self::AccountType,
219 password_hash: String,
220 ) -> ResponseResult<Self::AccountType>;
221
222 /// Fetches the account with the given email from the database, case-insensitively. Note that a lowercase email should be passed to this function, but the matching email as stored in the database may be in any case.
223 async fn fetch_account(
224 &self,
225 email: &str,
226 ) -> ResponseResult<Option<(Self::AccountType, String)>>;
227}
228
229///The non-error outcomes of registration. Error outcomes are used when a genuine error takes place — e.g. the database is not reachable (represented by the functions on your DataProvider implementation returning an error).
230pub enum RegistrationOutcome<AccountType: Account> {
231 ///The account is now in the database, and is given here.
232 Successful(AccountType),
233 ///The provided email is not a valid email
234 InvalidEmail,
235 //The provided email is already taken
236 EmailTaken,
237}
238
239///The non-error outcomes of logging in. Error outcomes are used when a genuine error takes place — e.g. the database is not reachable (represented by the functions on your DataProvider implementation returning an error).
240pub enum LoginOutcome<AccountType> {
241 ///The credentials were correct, so the account and a cookie that should be set in the response to the login route are provided.
242 Successful(AccountType, Cookie<'static>),
243 //The provided credentials do not correspond to a valid account.
244 InvalidEmailOrPassword,
245}
246
247//from https://emailregex.com/
248static EMAIL_REGEX: Lazy<Regex> = Lazy::new(|| {
249 Regex::new(
250 r###"(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])"###,
251 ).expect("Failed to parse email regex")
252});
253
254#[derive(Debug, Serialize, Deserialize)]
255struct JsonWebTokenClaims<T> {
256 exp: usize,
257 account: T,
258}
259
260///A clone of this struct is provided to each App instance in Actix as Data, thus providing access to the authentication system in each route.
261#[derive(Clone)]
262pub struct AuthenticationProvider<DataProviderImpl: DataProvider> {
263 provider: DataProviderImpl,
264 jwt_encoding_key: EncodingKey,
265 jwt_secret: Vec<u8>,
266}
267
268impl<DataProviderImpl: DataProvider> AuthenticationProvider<DataProviderImpl> {
269 ///Creates a new AuthenticationProvider with the provided jwt_secret and data provider. The jwt secret is used to sign and verify the json web tokens, so it should be secret, long enough to be secure, and persistent over a period of days. Changing this token will invalidate all current sessions, but they may not be cleanly logged out if you set your own cookies in addition to the token.
270 pub fn new(provider: DataProviderImpl, jwt_secret: Vec<u8>) -> Self {
271 Self {
272 provider,
273 jwt_encoding_key: EncodingKey::from_secret(&jwt_secret),
274 jwt_secret,
275 }
276 }
277
278 ///Registers the provided account with the provided password. See the documentation on RegistrationOutcome for details on what to do next.
279 /// ```rust,ignore
280 /// #[post("/register")]
281 /// async fn register(auth: Data<ExampleAuthProvider>, dto: Json<RegistrationDto>) -> Response {
282 /// let dto = dto.into_inner();
283 /// Ok(
284 /// match auth.register(
285 /// ExampleAccount {
286 /// username: dto.username,
287 /// email: dto.email,
288 /// },
289 /// &dto.password,
290 /// )? {
291 /// RegistrationOutcome::Successful(_account) => {
292 /// HttpResponse::Ok()
293 /// .json(RegistrationResponseDto {
294 /// succeeded: true,
295 /// message: None,
296 /// })
297 /// .await?
298 /// }
299 /// RegistrationOutcome::InvalidEmail => {
300 /// HttpResponse::Ok()
301 /// .json(RegistrationResponseDto {
302 /// succeeded: false,
303 /// message: Some("Invalid Email".into()),
304 /// })
305 /// .await?
306 /// }
307 /// RegistrationOutcome::EmailTaken => {
308 /// HttpResponse::Ok()
309 /// .json(RegistrationResponseDto {
310 /// succeeded: false,
311 /// message: Some("Email is already taken".into()),
312 /// })
313 /// .await?
314 /// }
315 /// },
316 /// )
317 /// }
318 /// ```
319 pub async fn register(
320 &self,
321 account: DataProviderImpl::AccountType,
322 password: &str,
323 ) -> ResponseResult<RegistrationOutcome<DataProviderImpl::AccountType>> {
324 let lowercase_email = account.email().to_ascii_lowercase();
325
326 //verify that email is a valid email
327 if !EMAIL_REGEX.is_match(&lowercase_email) {
328 return Ok(RegistrationOutcome::InvalidEmail);
329 }
330
331 //check for existing account with same username
332 if let Some(_) = self.provider.fetch_account(&lowercase_email).await? {
333 return Ok(RegistrationOutcome::EmailTaken);
334 }
335
336 //hash password
337 let salt = SaltString::generate(&mut OsRng);
338 let argon2 = Argon2::default();
339 let hash = argon2
340 .hash_password_simple(password.as_bytes(), salt.as_ref())?
341 .to_string();
342
343 //insert new account
344 let account = self.provider.insert_account(account, hash).await?;
345
346 return Ok(RegistrationOutcome::Successful(account));
347 }
348
349 ///Attempts to login to the specified account. See the documentation on LoginOutcome for details on what to do next.
350 /// ```rust,ignore
351 /// #[post("/login")]
352 /// async fn login(auth: Data<ExampleAuthProvider>, dto: Json<LoginDto>) -> Response {
353 /// Ok(match auth.login(&dto.email, &dto.password)? {
354 /// LoginOutcome::Successful(account, cookie) => {
355 /// HttpResponse::Ok()
356 /// .cookie(CookieBuilder::new("username", account.username).finish()) //this is how you make information available to your frontend, note that anything in your account type is visible to users as it is encoded as a JWT!!!!!
357 /// .cookie(cookie)
358 /// .json(LoginResponseDto {
359 /// succeeded: true,
360 /// message: None,
361 /// })
362 /// .await?
363 /// }
364 /// LoginOutcome::InvalidEmailOrPassword => {
365 /// HttpResponse::Ok()
366 /// .json(LoginResponseDto {
367 /// succeeded: false,
368 /// message: Some("Invalid username or password".into()),
369 /// })
370 /// .await?
371 /// }
372 /// })
373 /// }
374 /// ```
375 pub async fn login(
376 &self,
377 email: &str,
378 password: &str,
379 ) -> ResponseResult<LoginOutcome<DataProviderImpl::AccountType>> {
380 //get account & verify exists
381 let (account, hash_string) = match self
382 .provider
383 .fetch_account(&email.to_ascii_lowercase())
384 .await?
385 {
386 Some(account) => account,
387 None => return Ok(LoginOutcome::InvalidEmailOrPassword),
388 };
389
390 //check password
391 let argon2 = Argon2::default();
392 let password_hash = PasswordHash::new(&hash_string)?;
393 if !argon2
394 .verify_password(password.as_bytes(), &password_hash)
395 .is_ok()
396 {
397 return Ok(LoginOutcome::InvalidEmailOrPassword);
398 }
399
400 //issue token
401 let claims = JsonWebTokenClaims {
402 account,
403 exp: current_unix_time_secs() as usize + 24 * 3600, //expire 24 hours after issue
404 };
405 let token = encode(&Header::default(), &claims, &self.jwt_encoding_key)?;
406
407 //create cookie
408 let cookie = CookieBuilder::new("actix-plus-auth-token", token)
409 .secure(true)
410 .http_only(true)
411 .path("/")
412 .same_site(SameSite::Strict)
413 .finish();
414
415 Ok(LoginOutcome::Successful(claims.account, cookie))
416 }
417
418 ///Gets the current user if a valid session is present on the provided HTTP request, otherwise returns a ResponseResult that when propagated with the actix-plus-error crate causes Actix web to return 401 Not Authorized.
419 /// ```rust,ignore
420 /// #[get("/private_page")]
421 /// async fn private_page(request: HttpRequest, auth: Data<ExampleAuthProvider>) -> Response {
422 /// let account = auth.current_user(&request)?;
423 /// Ok(HttpResponse::Ok()
424 /// .body(format!("Hello {}", account.username))
425 /// .await?)
426 /// }
427 /// ```
428 pub fn current_user(
429 &self,
430 request: &HttpRequest,
431 ) -> ResponseResult<DataProviderImpl::AccountType> {
432 //check for cookie
433 let cookie: Cookie<'static> = match request.cookie("actix-plus-auth-token") {
434 Some(cookie) => cookie,
435 None => {
436 return Err(ResponseError::StatusCodeError {
437 message: "Unauthorized".into(),
438 code: StatusCode::UNAUTHORIZED,
439 })
440 }
441 };
442
443 //check token
444 let token = match decode::<JsonWebTokenClaims<DataProviderImpl::AccountType>>(
445 &cookie.value(),
446 &DecodingKey::from_secret(&self.jwt_secret),
447 &Validation::default(),
448 ) {
449 Ok(token) => token,
450 Err(_) => {
451 return Err(ResponseError::StatusCodeError {
452 message: "Unauthorized".into(),
453 code: StatusCode::UNAUTHORIZED,
454 })
455 }
456 };
457
458 //return user if token is valid
459 Ok(token.claims.account)
460 }
461}