diff --git a/back/.env.example b/back/.env.example index 0b3a2aef..42beea2d 100644 --- a/back/.env.example +++ b/back/.env.example @@ -4,6 +4,13 @@ # http route prefix (will listen to $KYOO_PREFIX/movie for example) KYOO_PREFIX="" + +# Optional authentication settings +# Set to true to disable login with password (OIDC auth must be configured) +# AUTHENTICATION_DISABLE_PASSWORD_LOGIN=true +# Set to true to disable the creation of new users (OIDC auth must be configured) +# AUTHENTICATION_DISABLE_USER_REGISTRATION=true + # Postgres settings # POSTGRES_URL=postgres://user:password@hostname:port/dbname?sslmode=verify-full&sslrootcert=/path/to/server.crt&sslcert=/path/to/client.crt&sslkey=/path/to/client.key # The behavior of the below variables match what is documented here: diff --git a/back/src/Kyoo.Authentication/Attributes/DisableOnEnvVarAttribute.cs b/back/src/Kyoo.Authentication/Attributes/DisableOnEnvVarAttribute.cs new file mode 100644 index 00000000..50e2e67d --- /dev/null +++ b/back/src/Kyoo.Authentication/Attributes/DisableOnEnvVarAttribute.cs @@ -0,0 +1,22 @@ +using System; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Kyoo.Authentication.Attributes; + +/// +/// Disables the action if the specified environment variable is set to true. +/// +public class DisableOnEnvVarAttribute(string varName) : Attribute, IResourceFilter +{ + public void OnResourceExecuting(ResourceExecutingContext context) + { + var config = context.HttpContext.RequestServices.GetRequiredService(); + + if (config.GetValue(varName, false)) + context.Result = new Microsoft.AspNetCore.Mvc.NotFoundResult(); + } + + public void OnResourceExecuted(ResourceExecutedContext context) { } +} diff --git a/back/src/Kyoo.Authentication/Models/DTO/ServerInfo.cs b/back/src/Kyoo.Authentication/Models/DTO/ServerInfo.cs index 6286cb7c..c7421a17 100644 --- a/back/src/Kyoo.Authentication/Models/DTO/ServerInfo.cs +++ b/back/src/Kyoo.Authentication/Models/DTO/ServerInfo.cs @@ -51,6 +51,16 @@ public class ServerInfo /// Check if kyoo's setup is finished. /// public SetupStep SetupStatus { get; set; } + + /// + /// True if password login is enabled on this instance. + /// + public bool PasswordLoginEnabled { get; set; } + + /// + /// True if registration is enabled on this instance. + /// + public bool RegistrationEnabled { get; set; } } public class OidcInfo diff --git a/back/src/Kyoo.Authentication/Views/AuthApi.cs b/back/src/Kyoo.Authentication/Views/AuthApi.cs index 550e1990..6fa1f4e9 100644 --- a/back/src/Kyoo.Authentication/Views/AuthApi.cs +++ b/back/src/Kyoo.Authentication/Views/AuthApi.cs @@ -26,6 +26,7 @@ using Kyoo.Abstractions.Models.Attributes; using Kyoo.Abstractions.Models.Exceptions; using Kyoo.Abstractions.Models.Permissions; using Kyoo.Abstractions.Models.Utils; +using Kyoo.Authentication.Attributes; using Kyoo.Authentication.Models; using Kyoo.Authentication.Models.DTO; using Kyoo.Models; @@ -206,6 +207,7 @@ public class AuthApi( [HttpPost("login")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] + [DisableOnEnvVar("AUTHENTICATION_DISABLE_PASSWORD_LOGIN")] public async Task> Login([FromBody] LoginRequest request) { User? user = await users.GetOrDefault( @@ -241,6 +243,7 @@ public class AuthApi( [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] [ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(RequestError))] + [DisableOnEnvVar("AUTHENTICATION_DISABLE_USER_REGISTRATION")] public async Task> Register([FromBody] RegisterRequest request) { try diff --git a/back/src/Kyoo.Core/Views/InfoApi.cs b/back/src/Kyoo.Core/Views/InfoApi.cs index 48cd132d..ac0e2f22 100644 --- a/back/src/Kyoo.Core/Views/InfoApi.cs +++ b/back/src/Kyoo.Core/Views/InfoApi.cs @@ -23,6 +23,7 @@ using Kyoo.Abstractions.Models.Attributes; using Kyoo.Authentication.Models; using Kyoo.Core.Controllers; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; using static Kyoo.Abstractions.Models.Utils.Constants; namespace Kyoo.Authentication.Views; @@ -33,7 +34,8 @@ namespace Kyoo.Authentication.Views; [ApiController] [Route("info")] [ApiDefinition("Info", Group = UsersGroup)] -public class InfoApi(PermissionOption options, MiscRepository info) : ControllerBase +public class InfoApi(PermissionOption options, MiscRepository info, IConfiguration configuration) + : ControllerBase { public async Task> GetInfo() { @@ -51,6 +53,14 @@ public class InfoApi(PermissionOption options, MiscRepository info) : Controller )) .ToDictionary(x => x.Key, x => x.Value), SetupStatus = await info.GetSetupStep(), + PasswordLoginEnabled = !configuration.GetValue( + "AUTHENTICATION_DISABLE_PASSWORD_LOGIN", + false + ), + RegistrationEnabled = !configuration.GetValue( + "AUTHENTICATION_DISABLE_USER_REGISTRATION", + false + ), } ); } diff --git a/front/packages/models/src/resources/server-info.ts b/front/packages/models/src/resources/server-info.ts index 05fa90e6..d0319f13 100644 --- a/front/packages/models/src/resources/server-info.ts +++ b/front/packages/models/src/resources/server-info.ts @@ -61,6 +61,14 @@ export const ServerInfoP = z * Check if kyoo's setup is finished. */ setupStatus: z.nativeEnum(SetupStep), + /* + * True if password login is enabled on this instance. + */ + passwordLoginEnabled: z.boolean(), + /* + * True if registration is enabled on this instance. + */ + registrationEnabled: z.boolean(), }) .transform((x) => { const baseUrl = Platform.OS === "web" ? x.publicUrl : "kyoo://"; diff --git a/front/packages/ui/src/login/login.tsx b/front/packages/ui/src/login/login.tsx index 34621273..a32193fc 100644 --- a/front/packages/ui/src/login/login.tsx +++ b/front/packages/ui/src/login/login.tsx @@ -18,11 +18,10 @@ * along with Kyoo. If not, see . */ -import { type QueryPage, login } from "@kyoo/models"; +import { type QueryPage, ServerInfoP, login, useFetch } from "@kyoo/models"; import { A, Button, H1, Input, P, ts } from "@kyoo/primitives"; import { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Trans } from "react-i18next"; +import { Trans, useTranslation } from "react-i18next"; import { Platform } from "react-native"; import { useRouter } from "solito/router"; import { percent, px, useYoshiki } from "yoshiki/native"; @@ -35,6 +34,11 @@ export const LoginPage: QueryPage<{ apiUrl?: string; error?: string }> = ({ apiUrl, error: initialError, }) => { + const { data } = useFetch({ + path: ["info"], + parser: ServerInfoP, + }); + const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(initialError); @@ -53,43 +57,47 @@ export const LoginPage: QueryPage<{ apiUrl?: string; error?: string }> = ({ return ( {t("login.login")} - - {t("login.username")} - setUsername(value)} - autoCapitalize="none" - /> - {t("login.password")} - setPassword(value)} - /> - {error && theme.colors.red })}>{error}} - { - const { error } = await login("login", { - username, - password, - apiUrl, - }); - setError(error); - if (error) return; - router.replace("/", undefined, { - experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false }, - }); - }} - {...css({ - m: ts(1), - width: px(250), - maxWidth: percent(100), - alignSelf: "center", - mY: ts(3), - })} - /> + + {data?.passwordLoginEnabled && ( + <> + {t("login.username")} + setUsername(value)} + autoCapitalize="none" + /> + {t("login.password")} + setPassword(value)} + /> + {error && theme.colors.red })}>{error}} + { + const { error } = await login("login", { + username, + password, + apiUrl, + }); + setError(error); + if (error) return; + router.replace("/", undefined, { + experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false }, + }); + }} + {...css({ + m: ts(1), + width: px(250), + maxWidth: percent(100), + alignSelf: "center", + mY: ts(3), + })} + /> + > + )} Don’t have an account? Register diff --git a/front/packages/ui/src/login/oidc.tsx b/front/packages/ui/src/login/oidc.tsx index 1f06ad5a..519a93b9 100644 --- a/front/packages/ui/src/login/oidc.tsx +++ b/front/packages/ui/src/login/oidc.tsx @@ -34,7 +34,7 @@ import { useRouter } from "solito/router"; import { percent, rem, useYoshiki } from "yoshiki/native"; import { ErrorView } from "../errors"; -export const OidcLogin = ({ apiUrl }: { apiUrl?: string }) => { +export const OidcLogin = ({ apiUrl, hideOr }: { apiUrl?: string; hideOr?: boolean }) => { const { css } = useYoshiki(); const { t } = useTranslation(); const { data, error } = useFetch({ options: { apiUrl }, ...OidcLogin.query() }); @@ -76,6 +76,7 @@ export const OidcLogin = ({ apiUrl }: { apiUrl?: string }) => { flexDirection: "row", width: percent(100), alignItems: "center", + display: hideOr ? "none" : undefined, })} > diff --git a/front/packages/ui/src/login/register.tsx b/front/packages/ui/src/login/register.tsx index 23d2c772..b38b421d 100644 --- a/front/packages/ui/src/login/register.tsx +++ b/front/packages/ui/src/login/register.tsx @@ -18,11 +18,10 @@ * along with Kyoo. If not, see . */ -import { type QueryPage, login } from "@kyoo/models"; +import { type QueryPage, ServerInfoP, login, useFetch } from "@kyoo/models"; import { A, Button, H1, Input, P, ts } from "@kyoo/primitives"; import { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Trans } from "react-i18next"; +import { Trans, useTranslation } from "react-i18next"; import { Platform } from "react-native"; import { useRouter } from "solito/router"; import { percent, px, useYoshiki } from "yoshiki/native"; @@ -32,6 +31,11 @@ import { OidcLogin } from "./oidc"; import { PasswordInput } from "./password-input"; export const RegisterPage: QueryPage<{ apiUrl?: string }> = ({ apiUrl }) => { + const { data } = useFetch({ + path: ["info"], + parser: ServerInfoP, + }); + const [email, setEmail] = useState(""); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); @@ -52,50 +56,58 @@ export const RegisterPage: QueryPage<{ apiUrl?: string }> = ({ apiUrl }) => { return ( {t("login.register")} - - {t("login.username")} - setUsername(value)} /> + + {data?.registrationEnabled && ( + <> + {t("login.username")} + setUsername(value)} + /> - {t("login.email")} - setEmail(value)} /> + {t("login.email")} + setEmail(value)} /> - {t("login.password")} - setPassword(value)} - /> + {t("login.password")} + setPassword(value)} + /> - {t("login.confirm")} - setConfirm(value)} - /> + {t("login.confirm")} + setConfirm(value)} + /> - {password !== confirm && ( - theme.colors.red })}>{t("login.password-no-match")} + {password !== confirm && ( + theme.colors.red })}>{t("login.password-no-match")} + )} + {error && theme.colors.red })}>{error}} + { + const { error } = await login("register", { email, username, password, apiUrl }); + setError(error); + if (error) return; + router.replace("/", undefined, { + experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false }, + }); + }} + {...css({ + m: ts(1), + width: px(250), + maxWidth: percent(100), + alignSelf: "center", + mY: ts(3), + })} + /> + > )} - {error && theme.colors.red })}>{error}} - { - const { error } = await login("register", { email, username, password, apiUrl }); - setError(error); - if (error) return; - router.replace("/", undefined, { - experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false }, - }); - }} - {...css({ - m: ts(1), - width: px(250), - maxWidth: percent(100), - alignSelf: "center", - mY: ts(3), - })} - /> Have an account already? Log in.
{t("login.username")}
{t("login.password")}
theme.colors.red })}>{error}
Don’t have an account? Register diff --git a/front/packages/ui/src/login/oidc.tsx b/front/packages/ui/src/login/oidc.tsx index 1f06ad5a..519a93b9 100644 --- a/front/packages/ui/src/login/oidc.tsx +++ b/front/packages/ui/src/login/oidc.tsx @@ -34,7 +34,7 @@ import { useRouter } from "solito/router"; import { percent, rem, useYoshiki } from "yoshiki/native"; import { ErrorView } from "../errors"; -export const OidcLogin = ({ apiUrl }: { apiUrl?: string }) => { +export const OidcLogin = ({ apiUrl, hideOr }: { apiUrl?: string; hideOr?: boolean }) => { const { css } = useYoshiki(); const { t } = useTranslation(); const { data, error } = useFetch({ options: { apiUrl }, ...OidcLogin.query() }); @@ -76,6 +76,7 @@ export const OidcLogin = ({ apiUrl }: { apiUrl?: string }) => { flexDirection: "row", width: percent(100), alignItems: "center", + display: hideOr ? "none" : undefined, })} > diff --git a/front/packages/ui/src/login/register.tsx b/front/packages/ui/src/login/register.tsx index 23d2c772..b38b421d 100644 --- a/front/packages/ui/src/login/register.tsx +++ b/front/packages/ui/src/login/register.tsx @@ -18,11 +18,10 @@ * along with Kyoo. If not, see . */ -import { type QueryPage, login } from "@kyoo/models"; +import { type QueryPage, ServerInfoP, login, useFetch } from "@kyoo/models"; import { A, Button, H1, Input, P, ts } from "@kyoo/primitives"; import { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Trans } from "react-i18next"; +import { Trans, useTranslation } from "react-i18next"; import { Platform } from "react-native"; import { useRouter } from "solito/router"; import { percent, px, useYoshiki } from "yoshiki/native"; @@ -32,6 +31,11 @@ import { OidcLogin } from "./oidc"; import { PasswordInput } from "./password-input"; export const RegisterPage: QueryPage<{ apiUrl?: string }> = ({ apiUrl }) => { + const { data } = useFetch({ + path: ["info"], + parser: ServerInfoP, + }); + const [email, setEmail] = useState(""); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); @@ -52,50 +56,58 @@ export const RegisterPage: QueryPage<{ apiUrl?: string }> = ({ apiUrl }) => { return ( {t("login.register")} - - {t("login.username")} - setUsername(value)} /> + + {data?.registrationEnabled && ( + <> + {t("login.username")} + setUsername(value)} + /> - {t("login.email")} - setEmail(value)} /> + {t("login.email")} + setEmail(value)} /> - {t("login.password")} - setPassword(value)} - /> + {t("login.password")} + setPassword(value)} + /> - {t("login.confirm")} - setConfirm(value)} - /> + {t("login.confirm")} + setConfirm(value)} + /> - {password !== confirm && ( - theme.colors.red })}>{t("login.password-no-match")} + {password !== confirm && ( + theme.colors.red })}>{t("login.password-no-match")} + )} + {error && theme.colors.red })}>{error}} + { + const { error } = await login("register", { email, username, password, apiUrl }); + setError(error); + if (error) return; + router.replace("/", undefined, { + experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false }, + }); + }} + {...css({ + m: ts(1), + width: px(250), + maxWidth: percent(100), + alignSelf: "center", + mY: ts(3), + })} + /> + > )} - {error && theme.colors.red })}>{error}} - { - const { error } = await login("register", { email, username, password, apiUrl }); - setError(error); - if (error) return; - router.replace("/", undefined, { - experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false }, - }); - }} - {...css({ - m: ts(1), - width: px(250), - maxWidth: percent(100), - alignSelf: "center", - mY: ts(3), - })} - /> Have an account already? Log in.
{t("login.email")}
{t("login.confirm")}
theme.colors.red })}>{t("login.password-no-match")}
Have an account already? Log in.