diff --git a/Cargo.lock b/Cargo.lock index ea6e826..f4bdb66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2948,12 +2948,6 @@ dependencies = [ "digest", ] -[[package]] -name = "sha1_smol" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" - [[package]] name = "sha2" version = "0.10.8" @@ -3813,7 +3807,6 @@ checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" dependencies = [ "getrandom 0.3.2", "serde", - "sha1_smol", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 85eab2f..61551a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ dotenvy = "0.15.7" rocket_dyn_templates = { version = "0.2.0", features = ["handlebars"] } humanize-bytes = "1.0.6" rocket-session-store = "0.2.1" -uuid = { version = "1.16.0", features = ["v4", "v5"] } +uuid = { version = "1.16.0", features = ["v4"] } rand = { version = "0.9.0", features = ["thread_rng"] } bcrypt = "0.17.0" openidconnect = "4.0.0" diff --git a/README.md b/README.md index 9a7a990..5a66578 100644 --- a/README.md +++ b/README.md @@ -33,12 +33,7 @@ _The current files list when logged in_ git clone https://github.com/jackzmc/storage.git cd storage -# Copy the sample config -cp config.sample.toml config.toml -# Edit the config.toml or provide the equivalant settings with env -# ex: [auth.oidc] ---> STORAGE_auth.oidc.issuer__url -# issuer-url -# Configure your database (requires to be set by env for now) +# Configure your database (create .env file with your PostgreSQL connection) echo "DATABASE_URL=postgres://username:password@localhost" > .env # Build the project @@ -57,18 +52,12 @@ Rough roadmap in a rough order of priority * [ ] WebDAV Support * [ ] Email support (for password resets, user invites) - * [ ] Email sender utility - * [ ] Individual email actions * [ ] SSO Support (openid) - * [x] Basic implementation - * [ ] User mapping - * [ ] User creation - * [ ] User logout -* [ ] S3 backend support * [ ] Administration panel * [ ] Add storage backends * [ ] Manage users * [ ] Change app settings +* [ ] S3 backend support ## Documentation diff --git a/config.sample.toml b/config.sample.toml index ccfe194..478d66e 100644 --- a/config.sample.toml +++ b/config.sample.toml @@ -26,7 +26,6 @@ claims = ["email", "profile"] # Should an account be created if SSO user id doesn't exist already create_account = true # Should normal login (username/email+pass) be disabled, forcing users to use sso? -# If enabled and disable_registration is enabled, the login page will redirect to SSO page directly disable_normal_login = false [smtp] diff --git a/src/config.rs b/src/config.rs index 3becf34..7705a22 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::env::var; use figment::Figment; use figment::providers::{Env, Format, Toml}; -use log::{debug, error}; +use log::error; use openidconnect::core::{CoreClient, CoreProviderMetadata}; use openidconnect::IssuerUrl; use openidconnect::url::Url; @@ -55,12 +55,6 @@ pub struct AuthConfig { pub oidc: Option, } -impl AuthConfig { - pub fn oidc_enabled(&self) -> bool { - self.oidc.as_ref().map(|o| o.enabled).unwrap_or(false) - } -} - #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "kebab-case")] pub struct OidcConfig { diff --git a/src/main.rs b/src/main.rs index 7fc2d41..aa4426b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,7 +27,6 @@ use routes::api; use crate::config::{get_settings, AppConfig}; use crate::consts::{init_statics, SESSION_COOKIE_NAME, SESSION_LIFETIME_SECONDS}; use crate::managers::sso::{SSOState, SSO}; -use crate::managers::user::UsersState; use crate::models::user::UserModel; use crate::routes::ui; @@ -116,10 +115,6 @@ async fn rocket() -> _ { let sso: SSOState = { if settings.auth.oidc.is_some() { Some(Arc::new(Mutex::new(SSO::create(&settings).await)) ) } else { None } }; - let users: UsersState = { - // TODO: somehow need to get store - UsersState::new(pool.clone()) - }; let figment = rocket::Config::figment() .merge(("port", listen_addr.port())) diff --git a/src/managers.rs b/src/managers.rs index ae2a320..a43655c 100644 --- a/src/managers.rs +++ b/src/managers.rs @@ -1,4 +1,3 @@ pub mod repos; pub mod libraries; -pub mod sso; -pub mod user; \ No newline at end of file +pub mod sso; \ No newline at end of file diff --git a/src/managers/sso.rs b/src/managers/sso.rs index 2382a6e..2d1d51c 100644 --- a/src/managers/sso.rs +++ b/src/managers/sso.rs @@ -3,7 +3,7 @@ use std::net::IpAddr; use std::sync::{Arc, LazyLock}; use std::time::Duration; use anyhow::anyhow; -use log::{info, warn}; +use log::warn; use moka::future::Cache; use openidconnect::core::{CoreAuthDisplay, CoreAuthPrompt, CoreClaimName, CoreClient, CoreErrorResponseType, CoreGenderClaim, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm, CoreProviderMetadata, CoreRevocableToken, CoreRevocationErrorResponse, CoreTokenIntrospectionResponse, CoreTokenResponse}; use openidconnect::http::{HeaderMap, HeaderValue}; @@ -40,8 +40,7 @@ impl SSO { pub async fn create(config: &AppConfig) -> Self { let oidc_config = config.auth.oidc.as_ref().expect("OIDC config not provided"); let referer = config.general.get_public_url().domain().map(|s| s.to_string()); - let proxy_settings = SSO::setup_proxy(); - let http_client = SSO::setup_http_client(referer, proxy_settings); + let http_client = SSO::setup_http_client(referer, None); let issuer_url = IssuerUrl::new(oidc_config.issuer_url.to_string()).expect("bad issuer url"); let client_id = ClientId::new(oidc_config.client_id.to_string()); let client_secret = Some(ClientSecret::new(oidc_config.client_secret.to_string())); @@ -64,16 +63,6 @@ impl SSO { .build() } - fn setup_proxy() -> Option { - if let Ok(proxy_url) = var("DEV_PROXY_URL") { - return Some(HttpProxySettings { - url: proxy_url, - disable_cert_check: var("DEV_PROXY_DANGER_DISABLE_CERT_CHECK").is_ok() - }) - } - None - } - fn setup_http_client(referer: Option, proxy_settings: Option) -> reqwest::Client { let mut headers = HeaderMap::new(); // TODO: pull from config. @@ -86,10 +75,7 @@ impl SSO { .redirect(reqwest::redirect::Policy::none()) .default_headers(headers); if let Some(proxy) = proxy_settings { - info!("Using proxy url: {}", proxy.url); - if proxy.disable_cert_check { - warn!("!! DEV_PROXY_DANGER_DISABLE_CERT_CHECK is set: requests are proxied, ignoring certificates"); - } + warn!("DANGER_DEV_PROXY set, requests are being proxied & ignoring certificates"); builder = builder .proxy(reqwest::Proxy::https(proxy.url).unwrap()) .danger_accept_invalid_certs(proxy.disable_cert_check); @@ -125,10 +111,6 @@ impl SSO { self.scopes.iter().map(|c| Scope::new(c.to_string())).collect() } - pub fn provider_id(&self) -> String { - self.issuer_url.url().domain().expect("issuer has no domain").to_string() - } - pub async fn cache_set(&mut self, ip: IpAddr, data: SSOSessionData) { self.cache.insert(ip, data).await; } diff --git a/src/managers/user.rs b/src/managers/user.rs deleted file mode 100644 index 8ea7d32..0000000 --- a/src/managers/user.rs +++ /dev/null @@ -1,149 +0,0 @@ -use std::hash::{DefaultHasher, Hash, Hasher}; -use std::net::IpAddr; -use std::sync::Arc; -use anyhow::anyhow; -use rocket::futures::TryStreamExt; -use rocket::serde::Serialize; -use rocket::State; -use rocket_session_store::{Session, SessionStore, Store}; -use rocket_session_store::memory::MemoryStore; -use sqlx::{query, query_as, Pool, QueryBuilder}; -use uuid::Uuid; -use crate::config::AppConfig; -use crate::consts::{DISABLE_LOGIN_CHECK, ENCRYPTION_ROUNDS}; -use crate::{LoginSessionData, SessionData, DB}; -use crate::models::user::{UserAuthError, UserModel, UserModelWithPassword}; - -pub struct UserManager { - pool: DB, -} - -#[derive(Debug, Serialize)] -pub struct CreateUserOptions { - pub email: String, - pub username: String, - pub name: Option, -} - -pub type UsersState = UserManager; - - -pub enum FindUserOption { - Id(String), - Email(String), - Username(String), -} - -#[derive(Hash)] -pub struct SSOData { - pub(crate) provider_id: String, - pub(crate) sub: String -} - -impl UserManager { - pub fn new(pool: DB) -> Self { - Self { - pool, - } - } - pub fn generate_id(sso_data: Option) -> String { - if let Some(sso_data) = sso_data { - let mut s = DefaultHasher::new(); - sso_data.hash(&mut s); - format!("{:x}", s.finish()) - } else { - uuid::Uuid::new_v4().to_string() - } - } - - pub async fn fetch_user(&self, search_options: &[FindUserOption]) -> Result, anyhow::Error> { - if search_options.is_empty() { return Err(anyhow!("At least one search option must be included"))} - let mut query = QueryBuilder::new("select id, username, password, created_at, email, name from storage.users where "); - for option in search_options { - match option { - FindUserOption::Id(id) => { - query.push("id = $1"); - query.push_bind(id); - }, - FindUserOption::Email(email) => { - query.push("email = $1"); - query.push_bind(email); - } - FindUserOption::Username(username) => { - query.push("username = $1"); - query.push_bind(username); - } - }; - } - query.build_query_as::() - .fetch_optional(&self.pool) - .await - .map_err(|e| anyhow!(e)) - } - /// Returns user's id - pub async fn create_normal_user(&self, user: CreateUserOptions, plain_password: String) -> Result { - let password = bcrypt::hash(plain_password, ENCRYPTION_ROUNDS) - .map_err(|e| anyhow!(e))?; - let id = Self::generate_id(None); - self.create_user(id, user, Some(password)).await - } - /// Returns user's id - pub async fn create_sso_user(&self, user: CreateUserOptions, id: String) -> Result { - self.create_user(id, user, None).await - } - async fn create_user(&self, id: String, user: CreateUserOptions, encrypted_password: Option) -> Result { - query!( - "INSERT INTO storage.users (id, name, password, email, username) VALUES ($1, $2, $3, $4, $5)", - id, - user.name, - encrypted_password, - user.email, - user.username - ) - .execute(&self.pool) - .await?; - Ok(UserModel { - id, - username: user.username, - email: user.email, - created_at: Default::default(), - name: user.name, - }) - } - - pub async fn login_user_session(&self, user: UserModel, ip_address: IpAddr, sessions: &Session<'_, SessionData>) { - sessions.set(SessionData { - csrf_token: None, - login: Some(LoginSessionData { - user, - ip_address - }), - }).await.unwrap(); - } - - pub async fn login_normal_user(&self, email_or_usrname: &str, password: &str, ip: IpAddr, session: &Session<'_, SessionData>) -> Result { - let user = query_as!(UserModelWithPassword, - "select id, username, password, created_at, email, name from storage.users where email = $1 OR username = $1", email_or_usrname - ) - .fetch_optional(&self.pool) - .await - .map_err(|e| UserAuthError::DatabaseError(e))?; - let Some(user) = user else { - return Err(UserAuthError::UserNotFound); - }; - if let Some(db_password) = user.password { - if !*DISABLE_LOGIN_CHECK || bcrypt::verify(password, &db_password).map_err(|e| UserAuthError::EncryptionError(e))? { - let model = UserModel { - id: user.id, - email: user.email, - username: user.username, - created_at: user.created_at, - name: user.name - }; - self.login_user_session(model.clone(), ip, session).await; - return Ok(model) - } - } - Err(UserAuthError::PasswordInvalid) - } -} \ No newline at end of file diff --git a/src/models/library.rs b/src/models/library.rs index c251e32..4a68636 100644 --- a/src/models/library.rs +++ b/src/models/library.rs @@ -1,4 +1,3 @@ -use std::str::FromStr; use anyhow::anyhow; use chrono::NaiveDateTime; use rocket::serde::{Serialize, Deserialize}; @@ -14,7 +13,7 @@ use crate::user::User; #[derive(Debug, Serialize, Deserialize)] pub struct LibraryModel { pub id: Uuid, - pub owner_id: String, + pub owner_id: Uuid, pub repo_id: String, pub created_at: NaiveDateTime, pub name: String, @@ -27,7 +26,7 @@ pub struct LibraryWithRepoModel { } pub async fn get_library(pool: &DB, library_id: &str) -> Result, anyhow::Error> { - let library_id = Uuid::from_str(library_id)?; + let library_id = Uuid::parse_str(library_id)?; let library = query_as!(LibraryModel, "select * from storage.libraries where id = $1", library_id) .fetch_optional(pool) .await.map_err(anyhow::Error::from)?; diff --git a/src/models/user.rs b/src/models/user.rs index 6f9c887..de413c8 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -1,6 +1,5 @@ use std::error::Error; use std::fmt::{Display, Formatter}; -use std::net::IpAddr; use bcrypt::BcryptError; use chrono::NaiveDateTime; use rocket::form::Context; @@ -10,30 +9,28 @@ use rocket::form::error::Entity; use rocket::response::Responder; use rocket::serde::Serialize; use rocket::serde::uuid::Uuid; -use rocket_session_store::Session; use sqlx::{query_as, FromRow}; use crate::consts::{DISABLE_LOGIN_CHECK, ENCRYPTION_ROUNDS}; use crate::{LoginSessionData, SessionData, DB}; -use crate::managers::user::UsersState; use crate::models::repo::RepoModel; use crate::util::JsonErrorResponse; #[derive(Serialize, Clone, Debug, FromRow)] pub struct UserModel { - pub id: String, + pub id: Uuid, pub username: String, pub email: String, pub created_at: NaiveDateTime, - pub name: Option + pub name: String } #[derive(Serialize, Clone, Debug, FromRow)] pub struct UserModelWithPassword { - pub id: String, + pub id: Uuid, pub username: String, pub email: String, pub password: Option, pub created_at: NaiveDateTime, - pub name: Option + pub name: String } #[derive(Debug)] @@ -94,15 +91,27 @@ impl UserAuthError { pub async fn get_user(pool: &DB, user_id: &str) -> Result, anyhow::Error> { + let user_id = Uuid::parse_str(user_id)?; query_as!(UserModel, "select id, username, created_at, email, name from storage.users where id = $1", user_id) .fetch_optional(pool) .await.map_err(anyhow::Error::from) } -/// Validates user login form -pub async fn try_login_user_form(ctx: &mut Context<'_>, users: &UsersState, ip: IpAddr, session: &Session<'_, SessionData>) -> Result { +/// Validates user login form, returning Some on success or None (with ctx containing errors) on failure +pub async fn validate_user_form(ctx: &mut Context<'_>, pool: &DB) -> Option { let username = ctx.field_value("username").unwrap(); let password = ctx.field_value("password").unwrap(); // TODO: no unwrap - users.login_normal_user(username, password, ip, session).await + match validate_user(pool, username, password).await { + Ok(u) => Some(u), + Err(UserAuthError::PasswordInvalid | UserAuthError::UserNotFound) => { + ctx.push_error(form::Error::validation("Username or password is incorrect").with_entity(Entity::Form)); + None + }, + Err(e) => { + ctx.push_error(form::Error::custom(e)); + None + } + } + } pub async fn validate_user(pool: &DB, email_or_usrname: &str, password: &str) -> Result { let user = query_as!(UserModelWithPassword, @@ -126,4 +135,16 @@ pub async fn validate_user(pool: &DB, email_or_usrname: &str, password: &str) -> } } Err(UserAuthError::PasswordInvalid) +} + +pub struct CreateUserModel { + pub username: String, + pub email: String, + pub password: String, + pub name: String +} +pub async fn create_user(pool: &DB, user: CreateUserModel) -> Result { + let encrypted_pass = bcrypt::hash(user.password, ENCRYPTION_ROUNDS) + .map_err(|e| UserAuthError::EncryptionError(e))?; + todo!() } \ No newline at end of file diff --git a/src/routes/ui/auth.rs b/src/routes/ui/auth.rs index 63be64e..906d922 100644 --- a/src/routes/ui/auth.rs +++ b/src/routes/ui/auth.rs @@ -9,7 +9,7 @@ use rocket::http::uri::{Origin, Reference, Uri}; use rocket::response::Redirect; use rocket_dyn_templates::{context, Template}; use rocket_session_store::Session; -use crate::models::user::{validate_user, try_login_user_form, UserAuthError, UserModel}; +use crate::models::user::{validate_user, validate_user_form, UserAuthError, UserModel}; use crate::{GlobalMetadata, LoginSessionData, SessionData, DB}; use crate::guards::AuthUser; use crate::routes::ui; diff --git a/src/routes/ui/auth/login.rs b/src/routes/ui/auth/login.rs index abecc97..a7e1568 100644 --- a/src/routes/ui/auth/login.rs +++ b/src/routes/ui/auth/login.rs @@ -6,10 +6,8 @@ use rocket::http::{Header, Status}; use rocket_dyn_templates::{context, Template}; use rocket_session_store::Session; use crate::{GlobalMetadata, LoginSessionData, SessionData, DB}; -use crate::config::AppConfig; use crate::consts::{APP_METADATA, DISABLE_LOGIN_CHECK}; -use crate::managers::user::UsersState; -use crate::models::user::try_login_user_form; +use crate::models::user::validate_user_form; use crate::routes::ui::auth::HackyRedirectBecauseRocketBug; use crate::util::{set_csrf, validate_csrf_form}; @@ -18,9 +16,7 @@ pub async fn page( route: &Route, session: Session<'_, SessionData>, return_to: Option, - logged_out: Option, - settings: &State, - + logged_out: Option ) -> Template { // TODO: redirect if already logged in let csrf_token = set_csrf(&session).await; @@ -30,8 +26,7 @@ pub async fn page( form: &Context::default(), return_to, logged_out, - meta: APP_METADATA.clone(), - sso_enabled: settings.auth.oidc_enabled() + meta: APP_METADATA.clone() }) } @@ -50,21 +45,28 @@ struct LoginForm<'r> { #[post("/auth/login?", data = "
")] pub async fn handler( + pool: &State, route: &Route, ip_addr: IpAddr, session: Session<'_, SessionData>, mut form: Form>>, - users: &State, - settings: &State, return_to: Option, ) -> Result { trace!("handler"); - validate_csrf_form(&mut form.context, &session).await; - let user = try_login_user_form(&mut form.context, users.inner(), ip_addr, &session).await.ok(); - // TODO: use new users fetch user + if !*DISABLE_LOGIN_CHECK { + validate_csrf_form(&mut form.context, &session).await; + } + let user = validate_user_form(&mut form.context, &pool).await; trace!("check form"); if form.context.status() == Status::Ok { - if let Some(_) = &form.value { + if let Some(submission) = &form.value { + session.set(SessionData { + csrf_token: None, + login: Some(LoginSessionData { + user: user.expect("failed to acquire user but no errors"), // if validate_user_form returned None, form had errors, this shouldnt run, + ip_address: ip_addr, + }), + }).await.unwrap(); let mut return_to_path = return_to.unwrap_or("/".to_string()); if return_to_path == "" { return_to_path.push_str("/"); } debug!("returning user to {:?}", return_to_path); @@ -85,8 +87,7 @@ pub async fn handler( csrf_token: csrf_token, form: &form.context, return_to, - meta: APP_METADATA.clone(), - sso_enabled: settings.auth.oidc_enabled() + meta: APP_METADATA.clone() }; Err(Template::render("auth/login", &ctx)) } \ No newline at end of file diff --git a/src/routes/ui/auth/sso.rs b/src/routes/ui/auth/sso.rs index 28e5eaa..1f932f8 100644 --- a/src/routes/ui/auth/sso.rs +++ b/src/routes/ui/auth/sso.rs @@ -17,9 +17,7 @@ use reqwest::header::HeaderMap; use rocket::http::{Header, Status}; use rocket_dyn_templates::{context, Template}; use tokio::sync::MutexGuard; -use crate::config::AppConfig; use crate::managers::sso::{SSOSessionData, SSOState, SSO}; -use crate::managers::user::{CreateUserOptions, FindUserOption, SSOData, UserManager, UsersState}; use crate::routes::ui::auth::HackyRedirectBecauseRocketBug; async fn page_handler(sso: &State, ip: IpAddr, return_to: Option) -> Result { @@ -53,7 +51,7 @@ pub async fn page(ip: IpAddr, sso: &State, return_to: Option) }))) } -async fn callback_handler(sso: &State, ip: IpAddr, code: String, state: String) -> Result<(CoreUserInfoClaims, String, Option), anyhow::Error> { +async fn callback_handler(sso: &State, ip: IpAddr, code: String, state: String) -> Result<(CoreUserInfoClaims, Option), anyhow::Error> { let mut sso = sso.as_ref().ok_or_else(||anyhow!("SSO is not configured"))?.lock().await; let sess_data = sso.cache_take(ip).await.ok_or_else(|| anyhow!("No valid sso started"))?; if &state != sess_data.csrf_token.secret() { @@ -97,53 +95,16 @@ async fn callback_handler(sso: &State, ip: IpAddr, code: String, state .request_async(sso.http_client()) .await .map_err(|_| anyhow!("could not acquire user data"))?; - Ok((userinfo, sso.provider_id(), sess_data.return_to)) + Ok((userinfo, sess_data.return_to)) } #[get("/auth/sso/cb?&")] -pub async fn callback(session: Session<'_, SessionData>, config: &State, users: &State, ip: IpAddr, sso: &State, code: String, state: String) -> Result { - let (userinfo, provider_id, return_to) = callback_handler(sso, ip, code, state).await +pub async fn callback(session: Session<'_, SessionData>, ip: IpAddr, sso: &State, code: String, state: String) -> Result { + let (userinfo, return_to) = callback_handler(sso, ip, code, state).await .map_err(|e| (Status::InternalServerError, Template::render("errors/500", context! { error: e.to_string() })))?; - // Setup search for existing users: - // TODO: own method? - let sso_data = SSOData { - provider_id, - sub: userinfo.subject().to_string() - }; - let uid = UserManager::generate_id(Some(sso_data)); - let email = userinfo.email().ok_or_else(||(Status::InternalServerError, Template::render("errors/500", context! { - error: "Provider did not provide an email" - })))?.to_string(); - let username = userinfo.preferred_username().ok_or_else(||(Status::InternalServerError, Template::render("errors/500", context! { - error: "Provider did not provide an username" - })))?.to_string(); - let search_options = vec![FindUserOption::Id(uid.clone()), FindUserOption::Email(email.clone()), FindUserOption::Username(username.clone())]; - let mut user = users.fetch_user(&search_options).await.map_err(|e|(Status::InternalServerError, Template::render("errors/500", context! { - error: format!("Failed to find user: {}", e) - })))?; - debug!("existing user = {:?}", user); - if user.is_none() { - if config.auth.oidc.as_ref().unwrap().create_account { - return Err((Status::InternalServerError, Template::render("errors/403", context! { - error: "No account found linked to oidc provider and account creation has been disabled" - }))); - } - user = { - let u = users.create_sso_user(CreateUserOptions { - email, - username, - name: userinfo.name().unwrap().get(None).map(|s| s.to_string()), - }, uid).await.expect("later i fix"); - debug!("new user = {}", u.id); - Some(u) - } - } - let user = user.unwrap(); - users.login_user_session(user, ip, &session).await; debug!("user={:?}\nemail={:?}\nname={:?}", userinfo.subject(), userinfo.email(), userinfo.name()); - // TODO: login user to session, prob through UserManager/users let return_to = return_to.unwrap_or("/".to_string()); Ok(HackyRedirectBecauseRocketBug { inner: "Login successful, redirecting...".to_string(), diff --git a/templates/auth/login.html.hbs b/templates/auth/login.html.hbs index 38dd43a..0ad8b67 100644 --- a/templates/auth/login.html.hbs +++ b/templates/auth/login.html.hbs @@ -58,7 +58,7 @@
{{#if sso_enabled}} - Login with SSO + Login with SSO {{/if}}