diff --git a/Cargo.lock b/Cargo.lock index 23caec3941d..642859cf75c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -267,7 +267,7 @@ dependencies = [ "hyper", "hyper-tls", "indexmap", - "ipnetwork", + "ipnetwork 0.19.0", "lazy_static", "lettre", "minijinja", @@ -771,6 +771,8 @@ dependencies = [ "byteorder", "chrono", "diesel_derives", + "ipnetwork 0.18.0", + "libc", "pq-sys", "r2d2", "serde_json", @@ -1348,6 +1350,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879d54834c8c76457ef4293a689b2a8c59b076067ad77b15efafbb05f92a592b" +[[package]] +name = "ipnetwork" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4088d739b183546b239688ddbc79891831df421773df95e236daf7867866d355" +dependencies = [ + "serde", +] + [[package]] name = "ipnetwork" version = "0.19.0" diff --git a/Cargo.toml b/Cargo.toml index 3a419492762..b2bf44db133 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,7 +50,7 @@ cookie = { version = "=0.16.0", features = ["secure"] } dashmap = { version = "=5.2.0", features = ["raw-api"] } derive_deref = "=1.1.1" dialoguer = "=0.10.1" -diesel = { version = "=1.4.8", features = ["postgres", "serde_json", "chrono", "r2d2"] } +diesel = { version = "=1.4.8", features = ["postgres", "serde_json", "chrono", "r2d2", "network-address"] } diesel_full_text_search = "=1.0.1" diesel_migrations = { version = "=1.4.0", features = ["postgres"] } dotenv = "=0.15.0" diff --git a/migrations/2022-02-21-211645_create_sessions/down.sql b/migrations/2022-02-21-211645_create_sessions/down.sql new file mode 100644 index 00000000000..2690500f010 --- /dev/null +++ b/migrations/2022-02-21-211645_create_sessions/down.sql @@ -0,0 +1 @@ +DROP TABLE persistent_sessions; diff --git a/migrations/2022-02-21-211645_create_sessions/up.sql b/migrations/2022-02-21-211645_create_sessions/up.sql new file mode 100644 index 00000000000..9a427c90205 --- /dev/null +++ b/migrations/2022-02-21-211645_create_sessions/up.sql @@ -0,0 +1,12 @@ +CREATE TABLE persistent_sessions +( + id BIGSERIAL + CONSTRAINT persistent_sessions_pk + PRIMARY KEY, + user_id INTEGER NOT NULL, + hashed_token bytea NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + revoked BOOLEAN DEFAULT FALSE NOT NULL +); + +COMMENT ON TABLE persistent_sessions IS 'This table contains the hashed tokens for all of the cookie-based persistent sessions'; diff --git a/src/controllers/user/session.rs b/src/controllers/user/session.rs index 7dbd2a25330..a4acd9d0fa7 100644 --- a/src/controllers/user/session.rs +++ b/src/controllers/user/session.rs @@ -1,14 +1,19 @@ use crate::controllers::frontend_prelude::*; -use conduit_cookie::RequestSession; +use conduit_cookie::{RequestCookies, RequestSession}; +use cookie::Cookie; use oauth2::reqwest::http_client; use oauth2::{AuthorizationCode, Scope, TokenResponse}; +use thiserror::Error; use crate::email::Emails; use crate::github::GithubUser; -use crate::models::{NewUser, User}; +use crate::models::persistent_session::ParseSessionCookieError; +use crate::models::persistent_session::SessionCookie; +use crate::models::{NewUser, PersistentSession, User}; use crate::schema::users; use crate::util::errors::ReadOnlyMode; +use crate::Env; /// Handles the `GET /api/private/session/begin` route. /// @@ -102,7 +107,15 @@ pub fn authorize(req: &mut dyn RequestExt) -> EndpointResult { &*req.db_write()?, )?; - // Log in by setting a cookie and the middleware authentication + // Setup a persistent session for the newly logged in user. + let (_session, cookie) = PersistentSession::create(user.id).insert(&*req.db_write()?)?; + + // Setup persistent session cookie. + let secure = req.app().config.env() == Env::Production; + req.cookies_mut().add(cookie.build(secure)); + + // TODO(adsnaider): Remove as part of https://github.com/rust-lang/crates.io/issues/2630. + // Log in by setting a cookie and the middleware authentication. req.session_mut() .insert("user_id".to_string(), user.id.to_string()); @@ -139,9 +152,36 @@ fn save_user_to_database( }) } +#[derive(Error, Debug, PartialEq)] +pub enum LogoutError { + #[error("No session cookie found.")] + MissingSessionCookie, + #[error("Session cookie had an unexpected format.")] + SessionCookieMalformatted(#[from] ParseSessionCookieError), + #[error("Session is not in the database.")] + SessionNotInDB, +} + /// Handles the `DELETE /api/private/session` route. pub fn logout(req: &mut dyn RequestExt) -> EndpointResult { + // TODO(adsnaider): Remove as part of https://github.com/rust-lang/crates.io/issues/2630. req.session_mut().remove(&"user_id".to_string()); + + // Remove persistent session from database. + let session_cookie = req + .cookies() + .get(SessionCookie::SESSION_COOKIE_NAME) + .ok_or(LogoutError::MissingSessionCookie)? + .value() + .parse::()?; + + req.cookies_mut() + .remove(Cookie::named(SessionCookie::SESSION_COOKIE_NAME)); + + let conn = req.db_write()?; + let mut session = PersistentSession::find(session_cookie.session_id(), &conn)? + .ok_or(LogoutError::SessionNotInDB)?; + session.revoke().update(&conn)?; Ok(req.json(&true)) } diff --git a/src/controllers/util.rs b/src/controllers/util.rs index ca0074c0bb1..c0262d6cbbd 100644 --- a/src/controllers/util.rs +++ b/src/controllers/util.rs @@ -1,13 +1,14 @@ use chrono::Utc; -use conduit_cookie::RequestSession; +use conduit_cookie::RequestCookies; use super::prelude::*; - use crate::middleware::log_request; -use crate::models::{ApiToken, User}; +use crate::models::persistent_session::SessionCookie; +use crate::models::{ApiToken, PersistentSession, User}; use crate::util::errors::{ account_locked, forbidden, internal, AppError, AppResult, InsecurelyGeneratedTokenRevoked, }; +use conduit_cookie::RequestSession; #[derive(Debug)] pub struct AuthenticatedUser { @@ -67,6 +68,8 @@ fn verify_origin(req: &dyn RequestExt) -> AppResult<()> { fn authenticate_user(req: &dyn RequestExt) -> AppResult { let conn = req.db_write()?; + // TODO(adsnaider): Remove as part of https://github.com/rust-lang/crates.io/issues/2630. + // Log in with the session id. let session = req.session(); let user_id_from_session = session.get("user_id").and_then(|s| s.parse::().ok()); @@ -80,6 +83,26 @@ fn authenticate_user(req: &dyn RequestExt) -> AppResult { }); } + // Log in with persistent session token. + if let Some(Ok(session_cookie)) = req + .cookies() + .get(SessionCookie::SESSION_COOKIE_NAME) + .map(|cookie| cookie.value()) + .map(|cookie| cookie.parse::()) + { + if let Some(session) = PersistentSession::find(session_cookie.session_id(), &conn)? { + if session.is_authorized(session_cookie.token()) { + let user = User::find(&conn, session.user_id).map_err(|e| { + e.chain(internal("user_id from session not found in the database")) + })?; + return Ok(AuthenticatedUser { + user, + token_id: None, + }); + } + } + } + // Otherwise, look for an `Authorization` header on the request let maybe_authorization = req .headers() diff --git a/src/models.rs b/src/models.rs index 261427a1465..3d19ae9cddf 100644 --- a/src/models.rs +++ b/src/models.rs @@ -9,6 +9,7 @@ pub use self::follow::Follow; pub use self::keyword::{CrateKeyword, Keyword}; pub use self::krate::{Crate, CrateVersions, NewCrate, RecentCrateDownloads}; pub use self::owner::{CrateOwner, Owner, OwnerKind}; +pub use self::persistent_session::{NewPersistentSession, PersistentSession}; pub use self::rights::Rights; pub use self::team::{NewTeam, Team}; pub use self::token::{ApiToken, CreatedApiToken}; @@ -28,6 +29,7 @@ mod follow; mod keyword; pub mod krate; mod owner; +pub mod persistent_session; mod rights; mod team; mod token; diff --git a/src/models/persistent_session.rs b/src/models/persistent_session.rs new file mode 100644 index 00000000000..2f6122b53ff --- /dev/null +++ b/src/models/persistent_session.rs @@ -0,0 +1,177 @@ +use chrono::NaiveDateTime; +use cookie::{Cookie, SameSite}; +use diesel::prelude::*; +use std::num::ParseIntError; +use std::str::FromStr; +use thiserror::Error; + +use crate::schema::persistent_sessions; +use crate::util::token::NewSecureToken; +use crate::util::token::SecureToken; +use crate::util::token::SecureTokenKind; + +/// A persistent session model (as is stored in the database). +/// +/// The sessions table works by maintaining a `hashed_token`. In order for a user to securely +/// demonstrate authenticity, the user provides us with the token (stored as part of a cookie). We +/// hash the token and search in the database for matches. If we find one and the token hasn't +/// been revoked, then we update the session with the latest values and authorize the user. +#[derive(Clone, Debug, PartialEq, Eq, Identifiable, Queryable)] +#[table_name = "persistent_sessions"] +pub struct PersistentSession { + /// The id of this session. + pub id: i64, + /// The user id associated with this session. + pub user_id: i32, + /// The token (hashed) that identifies the session. + pub hashed_token: SecureToken, + /// Datetime the session was created. + pub created_at: NaiveDateTime, + /// Whether the session is revoked. + pub revoked: bool, +} + +impl PersistentSession { + /// Creates a `NewPersistentSession` for the `user_id` and the token associated with it. + pub fn create(user_id: i32) -> NewPersistentSession { + let token = SecureToken::generate(SecureTokenKind::Session); + NewPersistentSession { user_id, token } + } + + /// Finds the session with the ID. + /// + /// # Returns + /// + /// * `Ok(Some(...))` if a session matches the id. + /// * `Ok(None)` if no session matches the id. + /// * `Err(...)` for other errors.. + pub fn find(id: i64, conn: &PgConnection) -> Result, diesel::result::Error> { + persistent_sessions::table + .find(id) + .get_result(conn) + .optional() + } + + /// Updates the session in the database. + pub fn update(&self, conn: &PgConnection) -> Result<(), diesel::result::Error> { + diesel::update(persistent_sessions::table.find(self.id)) + .set(( + persistent_sessions::user_id.eq(&self.user_id), + persistent_sessions::hashed_token.eq(&self.hashed_token), + persistent_sessions::revoked.eq(&self.revoked), + )) + .get_result::(conn) + .map(|_| ()) + } + + pub fn is_authorized(&self, token: &str) -> bool { + if let Some(hashed_token) = SecureToken::parse(SecureTokenKind::Session, token) { + !self.revoked && self.hashed_token == hashed_token + } else { + false + } + } + + /// Revokes the session (needs update). + pub fn revoke(&mut self) -> &mut Self { + self.revoked = true; + self + } +} + +/// A new, insertable persistent session. +pub struct NewPersistentSession { + user_id: i32, + token: NewSecureToken, +} + +impl NewPersistentSession { + /// Inserts the session into the database. + /// + /// # Returns + /// + /// The + pub fn insert( + self, + conn: &PgConnection, + ) -> Result<(PersistentSession, SessionCookie), diesel::result::Error> { + let session: PersistentSession = diesel::insert_into(persistent_sessions::table) + .values(( + persistent_sessions::user_id.eq(&self.user_id), + persistent_sessions::hashed_token.eq(&*self.token), + )) + .get_result(conn)?; + let id = session.id; + Ok(( + session, + SessionCookie::new(id, self.token.plaintext().to_string()), + )) + } +} + +/// Holds the information needed for the session cookie. +#[derive(Debug, PartialEq, Eq)] +pub struct SessionCookie { + /// The session ID in the database. + id: i64, + /// The token + token: String, +} + +impl SessionCookie { + /// Name of the cookie used for session-based authentication. + pub const SESSION_COOKIE_NAME: &'static str = "__Host-auth"; + + /// Creates a new `SessionCookie`. + pub fn new(id: i64, token: String) -> Self { + Self { id, token } + } + + /// Returns the `[Cookie]`. + pub fn build(&self, secure: bool) -> Cookie<'static> { + Cookie::build( + Self::SESSION_COOKIE_NAME, + format!("{}:{}", self.id, &self.token), + ) + .http_only(true) + .secure(secure) + .same_site(SameSite::Strict) + .path("/") + .finish() + } + + pub fn session_id(&self) -> i64 { + self.id + } + + pub fn token(&self) -> &str { + &self.token + } +} + +/// Error returned when the session cookie couldn't be parsed. +#[derive(Error, Debug, PartialEq)] +pub enum ParseSessionCookieError { + #[error("The session id wasn't in the cookie.")] + MissingSessionId, + #[error("The session id couldn't be parsed from the cookie.")] + IdParseError(#[from] ParseIntError), + #[error("The session token wasn't in the cookie.")] + MissingToken, +} + +impl FromStr for SessionCookie { + type Err = ParseSessionCookieError; + fn from_str(s: &str) -> Result { + let mut id_and_token = s.split(':'); + let id: i64 = id_and_token + .next() + .ok_or(ParseSessionCookieError::MissingSessionId)? + .parse()?; + let token = id_and_token + .next() + .ok_or(ParseSessionCookieError::MissingToken)?; + + Ok(Self::new(id, token.to_string())) + } +} diff --git a/src/schema.rs b/src/schema.rs index 1cb6ffada2d..d9ce8db9c99 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -598,6 +598,47 @@ table! { } } +table! { + use diesel::sql_types::*; + use diesel_full_text_search::{TsVector as Tsvector}; + + /// Representation of the `persistent_sessions` table. + /// + /// (Automatically generated by Diesel.) + persistent_sessions (id) { + /// The `id` column of the `persistent_sessions` table. + /// + /// Its SQL type is `Int8`. + /// + /// (Automatically generated by Diesel.) + id -> Int8, + /// The `user_id` column of the `persistent_sessions` table. + /// + /// Its SQL type is `Int4`. + /// + /// (Automatically generated by Diesel.) + user_id -> Int4, + /// The `hashed_token` column of the `persistent_sessions` table. + /// + /// Its SQL type is `Bytea`. + /// + /// (Automatically generated by Diesel.) + hashed_token -> Bytea, + /// The `created_at` column of the `persistent_sessions` table. + /// + /// Its SQL type is `Timestamp`. + /// + /// (Automatically generated by Diesel.) + created_at -> Timestamp, + /// The `revoked` column of the `persistent_sessions` table. + /// + /// Its SQL type is `Bool`. + /// + /// (Automatically generated by Diesel.) + revoked -> Bool, + } +} + table! { use diesel::sql_types::*; use diesel_full_text_search::{TsVector as Tsvector}; @@ -627,6 +668,24 @@ table! { } } +table! { + /// Representation of the `recent_crate_downloads` view. + /// + /// This data represents the downloads in the last 90 days. + /// This view does not contain realtime data. + /// It is refreshed by the `update-downloads` script. + recent_crate_downloads (crate_id) { + /// The `crate_id` column of the `recent_crate_downloads` view. + /// + /// Its SQL type is `Integer`. + crate_id -> Integer, + /// The `downloads` column of the `recent_crate_downloads` table. + /// + /// Its SQL type is `BigInt`. + downloads -> BigInt, + } +} + table! { use diesel::sql_types::*; use diesel_full_text_search::{TsVector as Tsvector}; @@ -679,24 +738,6 @@ table! { } } -table! { - /// Representation of the `recent_crate_downloads` view. - /// - /// This data represents the downloads in the last 90 days. - /// This view does not contain realtime data. - /// It is refreshed by the `update-downloads` script. - recent_crate_downloads (crate_id) { - /// The `crate_id` column of the `recent_crate_downloads` view. - /// - /// Its SQL type is `Integer`. - crate_id -> Integer, - /// The `downloads` column of the `recent_crate_downloads` table. - /// - /// Its SQL type is `BigInt`. - downloads -> BigInt, - } -} - table! { use diesel::sql_types::*; use diesel_full_text_search::{TsVector as Tsvector}; @@ -1050,6 +1091,7 @@ allow_tables_to_appear_in_same_query!( follows, keywords, metadata, + persistent_sessions, publish_limit_buckets, publish_rate_overrides, readme_renderings, diff --git a/src/tests/authentication.rs b/src/tests/authentication.rs index 2af5a689588..ba45ba501b1 100644 --- a/src/tests/authentication.rs +++ b/src/tests/authentication.rs @@ -2,6 +2,7 @@ use crate::util::{RequestHelper, Response}; use crate::TestApp; use crate::util::encode_session_header; +use cargo_registry::models::persistent_session::SessionCookie; use conduit::{header, Body, Method, StatusCode}; static URL: &str = "/api/v1/me/updates"; @@ -9,6 +10,60 @@ static MUST_LOGIN: &[u8] = br#"{"errors":[{"detail":"must be logged in to perfor static INTERNAL_ERROR_NO_USER: &str = "user_id from cookie not found in database caused by NotFound"; +#[test] +fn persistent_session_user() { + let (app, _) = TestApp::init().empty(); + let user = app.db_new_user("user1").with_session(); + let request = user.request_builder(Method::GET, URL); + let response: Response = user.run(request); + assert_eq!(response.status(), StatusCode::OK); +} + +#[test] +fn persistent_session_revoked_after_logout() { + let (app, _) = TestApp::init().empty(); + let user = app.db_new_user("user1").with_session(); + let request = user.request_builder(Method::GET, URL); + let response: Response = user.run(request); + assert_eq!(response.status(), StatusCode::OK); + + // Logout + let request = user.request_builder(Method::DELETE, "/api/private/session"); + let response: Response = user.run(request); + assert_eq!(response.status(), StatusCode::OK); + + // Now this request should fail since we logged out. + let request = user.request_builder(Method::GET, URL); + let response: Response = user.run(request); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[test] +fn incorrect_session_is_forbidden() { + let (_, anon) = TestApp::init().empty(); + + let token = "scio:1234:abcdfdfdsafdsfd".to_string(); + // Create a cookie that isn't in the database. + let cookie = SessionCookie::new(123, token).build(false).to_string(); + let mut request = anon.request_builder(Method::GET, URL); + request.header(header::COOKIE, &cookie); + let response: Response = anon.run(request); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!( + response.into_json(), + json!({"errors": [{"detail": "must be logged in to perform that action"}]}) + ); +} + +#[test] +fn cookie_user() { + let (_, _, cookie_user) = TestApp::init().with_user(); + let request = cookie_user.request_builder(Method::GET, URL); + + let response: Response = cookie_user.run(request); + assert_eq!(response.status(), StatusCode::OK); +} + #[test] fn anonymous_user_unauthorized() { let (_, anon) = TestApp::init().empty(); diff --git a/src/tests/util.rs b/src/tests/util.rs index 20f1e15891c..d8e5ac79d0a 100644 --- a/src/tests/util.rs +++ b/src/tests/util.rs @@ -23,6 +23,8 @@ use crate::{ builders::PublishBuilder, CategoryListResponse, CategoryResponse, CrateList, CrateResponse, GoodCrate, OkBool, OwnersResponse, VersionResponse, }; +use cargo_registry::models::persistent_session::SessionCookie; +use cargo_registry::models::PersistentSession; use cargo_registry::models::{ApiToken, CreatedApiToken, User}; use conduit::{BoxError, Handler, Method}; @@ -271,6 +273,38 @@ impl MockCookieUser { token, } } + + pub fn with_session(&self) -> MockSessionUser { + let (_session, session_cookie) = self.app.db(|conn| { + PersistentSession::create(self.user.id) + .insert(conn) + .unwrap() + }); + + MockSessionUser { + app: self.app.clone(), + session_cookie, + } + } +} + +pub struct MockSessionUser { + app: TestApp, + session_cookie: SessionCookie, +} + +impl RequestHelper for MockSessionUser { + fn request_builder(&self, method: Method, path: &str) -> MockRequest { + let cookie = self.session_cookie.build(false).to_string(); + + let mut request = req(method, path); + request.header(header::COOKIE, &cookie); + request + } + + fn app(&self) -> &TestApp { + &self.app + } } /// A type that can generate token authenticated requests diff --git a/src/util/token.rs b/src/util/token.rs index 38869c2db15..ae56539e3f7 100644 --- a/src/util/token.rs +++ b/src/util/token.rs @@ -122,6 +122,7 @@ secure_token_kind! { #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] pub(crate) enum SecureTokenKind { Api => "cio", // Crates.IO + Session => "scio", // Session tokens. } } @@ -172,6 +173,7 @@ mod tests { }; ensure(SecureTokenKind::Api, "cio"); + ensure(SecureTokenKind::Session, "scio"); assert!( remaining.is_empty(), diff --git a/src/worker/dump_db/dump-db.toml b/src/worker/dump_db/dump-db.toml index 8d8b2dd9c23..24bd9f86eec 100644 --- a/src/worker/dump_db/dump-db.toml +++ b/src/worker/dump_db/dump-db.toml @@ -136,6 +136,13 @@ created_at = "public" [metadata.columns] total_downloads = "public" +[persistent_sessions.columns] +id = "private" +user_id = "private" +hashed_token = "private" +created_at = "private" +revoked = "private" + [publish_limit_buckets.columns] user_id = "private" tokens = "private"