diff --git a/Cargo.lock b/Cargo.lock index f4bdb66..ea6e826 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2948,6 +2948,12 @@ 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" @@ -3807,6 +3813,7 @@ checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" dependencies = [ "getrandom 0.3.2", "serde", + "sha1_smol", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 61551a7..85eab2f 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"] } +uuid = { version = "1.16.0", features = ["v4", "v5"] } 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 5a66578..9a7a990 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,12 @@ _The current files list when logged in_ git clone https://github.com/jackzmc/storage.git cd storage -# Configure your database (create .env file with your PostgreSQL connection) +# 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) echo "DATABASE_URL=postgres://username:password@localhost" > .env # Build the project @@ -52,12 +57,18 @@ 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 478d66e..ccfe194 100644 --- a/config.sample.toml +++ b/config.sample.toml @@ -26,6 +26,7 @@ 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 7705a22..3becf34 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::error; +use log::{debug, error}; use openidconnect::core::{CoreClient, CoreProviderMetadata}; use openidconnect::IssuerUrl; use openidconnect::url::Url; @@ -55,6 +55,12 @@ 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 aa4426b..7fc2d41 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,6 +27,7 @@ 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; @@ -115,6 +116,10 @@ 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 a43655c..ae2a320 100644 --- a/src/managers.rs +++ b/src/managers.rs @@ -1,3 +1,4 @@ pub mod repos; pub mod libraries; -pub mod sso; \ No newline at end of file +pub mod sso; +pub mod user; \ No newline at end of file diff --git a/src/managers/sso.rs b/src/managers/sso.rs index 2d1d51c..2382a6e 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::warn; +use log::{info, 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,7 +40,8 @@ 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 http_client = SSO::setup_http_client(referer, None); + let proxy_settings = SSO::setup_proxy(); + let http_client = SSO::setup_http_client(referer, proxy_settings); 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())); @@ -63,6 +64,16 @@ 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. @@ -75,7 +86,10 @@ impl SSO { .redirect(reqwest::redirect::Policy::none()) .default_headers(headers); if let Some(proxy) = proxy_settings { - warn!("DANGER_DEV_PROXY set, requests are being proxied & ignoring certificates"); + 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"); + } builder = builder .proxy(reqwest::Proxy::https(proxy.url).unwrap()) .danger_accept_invalid_certs(proxy.disable_cert_check); @@ -111,6 +125,10 @@ 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 new file mode 100644 index 0000000..8ea7d32 --- /dev/null +++ b/src/managers/user.rs @@ -0,0 +1,149 @@ +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 4a68636..c251e32 100644 --- a/src/models/library.rs +++ b/src/models/library.rs @@ -1,3 +1,4 @@ +use std::str::FromStr; use anyhow::anyhow; use chrono::NaiveDateTime; use rocket::serde::{Serialize, Deserialize}; @@ -13,7 +14,7 @@ use crate::user::User; #[derive(Debug, Serialize, Deserialize)] pub struct LibraryModel { pub id: Uuid, - pub owner_id: Uuid, + pub owner_id: String, pub repo_id: String, pub created_at: NaiveDateTime, pub name: String, @@ -26,7 +27,7 @@ pub struct LibraryWithRepoModel { } pub async fn get_library(pool: &DB, library_id: &str) -> Result, anyhow::Error> { - let library_id = Uuid::parse_str(library_id)?; + let library_id = Uuid::from_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 de413c8..6f9c887 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -1,5 +1,6 @@ use std::error::Error; use std::fmt::{Display, Formatter}; +use std::net::IpAddr; use bcrypt::BcryptError; use chrono::NaiveDateTime; use rocket::form::Context; @@ -9,28 +10,30 @@ 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: Uuid, + pub id: String, pub username: String, pub email: String, pub created_at: NaiveDateTime, - pub name: String + pub name: Option } #[derive(Serialize, Clone, Debug, FromRow)] pub struct UserModelWithPassword { - pub id: Uuid, + pub id: String, pub username: String, pub email: String, pub password: Option, pub created_at: NaiveDateTime, - pub name: String + pub name: Option } #[derive(Debug)] @@ -91,27 +94,15 @@ 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, returning Some on success or None (with ctx containing errors) on failure -pub async fn validate_user_form(ctx: &mut Context<'_>, pool: &DB) -> Option { +/// Validates user login form +pub async fn try_login_user_form(ctx: &mut Context<'_>, users: &UsersState, ip: IpAddr, session: &Session<'_, SessionData>) -> Result { let username = ctx.field_value("username").unwrap(); let password = ctx.field_value("password").unwrap(); // TODO: no unwrap - 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 - } - } - + users.login_normal_user(username, password, ip, session).await } pub async fn validate_user(pool: &DB, email_or_usrname: &str, password: &str) -> Result { let user = query_as!(UserModelWithPassword, @@ -135,16 +126,4 @@ 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 906d922..63be64e 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, validate_user_form, UserAuthError, UserModel}; +use crate::models::user::{validate_user, try_login_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 a7e1568..abecc97 100644 --- a/src/routes/ui/auth/login.rs +++ b/src/routes/ui/auth/login.rs @@ -6,8 +6,10 @@ 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::models::user::validate_user_form; +use crate::managers::user::UsersState; +use crate::models::user::try_login_user_form; use crate::routes::ui::auth::HackyRedirectBecauseRocketBug; use crate::util::{set_csrf, validate_csrf_form}; @@ -16,7 +18,9 @@ pub async fn page( route: &Route, session: Session<'_, SessionData>, return_to: Option, - logged_out: Option + logged_out: Option, + settings: &State, + ) -> Template { // TODO: redirect if already logged in let csrf_token = set_csrf(&session).await; @@ -26,7 +30,8 @@ pub async fn page( form: &Context::default(), return_to, logged_out, - meta: APP_METADATA.clone() + meta: APP_METADATA.clone(), + sso_enabled: settings.auth.oidc_enabled() }) } @@ -45,28 +50,21 @@ 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"); - if !*DISABLE_LOGIN_CHECK { - validate_csrf_form(&mut form.context, &session).await; - } - let user = validate_user_form(&mut form.context, &pool).await; + 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 trace!("check form"); if form.context.status() == Status::Ok { - 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(); + if let Some(_) = &form.value { 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); @@ -87,7 +85,8 @@ pub async fn handler( csrf_token: csrf_token, form: &form.context, return_to, - meta: APP_METADATA.clone() + meta: APP_METADATA.clone(), + sso_enabled: settings.auth.oidc_enabled() }; 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 1f932f8..28e5eaa 100644 --- a/src/routes/ui/auth/sso.rs +++ b/src/routes/ui/auth/sso.rs @@ -17,7 +17,9 @@ 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 { @@ -51,7 +53,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, Option), anyhow::Error> { +async fn callback_handler(sso: &State, ip: IpAddr, code: String, state: String) -> Result<(CoreUserInfoClaims, String, 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() { @@ -95,16 +97,53 @@ 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, sess_data.return_to)) + Ok((userinfo, sso.provider_id(), sess_data.return_to)) } #[get("/auth/sso/cb?&")] -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 +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 .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 0ad8b67..38dd43a 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}}