Added support for disabling password login and registration (#947)

Signed-off-by: Fred Heinecke <fred.heinecke@yahoo.com>
Signed-off-by: solidDoWant <fred.heinecke@yahoo.com>
This commit is contained in:
solidDoWant 2025-05-09 07:07:32 -05:00 committed by GitHub
parent ce63da1448
commit 1f573a2553
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 165 additions and 84 deletions

View File

@ -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:

View File

@ -0,0 +1,22 @@
using System;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Kyoo.Authentication.Attributes;
/// <summary>
/// Disables the action if the specified environment variable is set to true.
/// </summary>
public class DisableOnEnvVarAttribute(string varName) : Attribute, IResourceFilter
{
public void OnResourceExecuting(ResourceExecutingContext context)
{
var config = context.HttpContext.RequestServices.GetRequiredService<IConfiguration>();
if (config.GetValue(varName, false))
context.Result = new Microsoft.AspNetCore.Mvc.NotFoundResult();
}
public void OnResourceExecuted(ResourceExecutedContext context) { }
}

View File

@ -51,6 +51,16 @@ public class ServerInfo
/// Check if kyoo's setup is finished.
/// </summary>
public SetupStep SetupStatus { get; set; }
/// <summary>
/// True if password login is enabled on this instance.
/// </summary>
public bool PasswordLoginEnabled { get; set; }
/// <summary>
/// True if registration is enabled on this instance.
/// </summary>
public bool RegistrationEnabled { get; set; }
}
public class OidcInfo

View File

@ -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<ActionResult<JwtToken>> 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<ActionResult<JwtToken>> Register([FromBody] RegisterRequest request)
{
try

View File

@ -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<ActionResult<ServerInfo>> 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
),
}
);
}

View File

@ -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://";

View File

@ -18,11 +18,10 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
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<string | undefined>(initialError);
@ -53,43 +57,47 @@ export const LoginPage: QueryPage<{ apiUrl?: string; error?: string }> = ({
return (
<FormPage apiUrl={apiUrl}>
<H1>{t("login.login")}</H1>
<OidcLogin apiUrl={apiUrl} />
<P {...css({ paddingLeft: ts(1) })}>{t("login.username")}</P>
<Input
autoComplete="username"
variant="big"
onChangeText={(value) => setUsername(value)}
autoCapitalize="none"
/>
<P {...css({ paddingLeft: ts(1) })}>{t("login.password")}</P>
<PasswordInput
autoComplete="password"
variant="big"
onChangeText={(value) => setPassword(value)}
/>
{error && <P {...css({ color: (theme) => theme.colors.red })}>{error}</P>}
<Button
text={t("login.login")}
onPress={async () => {
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),
})}
/>
<OidcLogin apiUrl={apiUrl} hideOr={!data?.passwordLoginEnabled} />
{data?.passwordLoginEnabled && (
<>
<P {...css({ paddingLeft: ts(1) })}>{t("login.username")}</P>
<Input
autoComplete="username"
variant="big"
onChangeText={(value) => setUsername(value)}
autoCapitalize="none"
/>
<P {...css({ paddingLeft: ts(1) })}>{t("login.password")}</P>
<PasswordInput
autoComplete="password"
variant="big"
onChangeText={(value) => setPassword(value)}
/>
{error && <P {...css({ color: (theme) => theme.colors.red })}>{error}</P>}
<Button
text={t("login.login")}
onPress={async () => {
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),
})}
/>
</>
)}
<P>
<Trans i18nKey="login.or-register">
Dont have an account? <A href={{ pathname: "/register", query: { apiUrl } }}>Register</A>

View File

@ -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,
})}
>
<HR {...css({ flexGrow: 1 })} />

View File

@ -18,11 +18,10 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
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 (
<FormPage apiUrl={apiUrl}>
<H1>{t("login.register")}</H1>
<OidcLogin apiUrl={apiUrl} />
<P {...css({ paddingLeft: ts(1) })}>{t("login.username")}</P>
<Input autoComplete="username" variant="big" onChangeText={(value) => setUsername(value)} />
<OidcLogin apiUrl={apiUrl} hideOr={!data?.passwordLoginEnabled} />
{data?.registrationEnabled && (
<>
<P {...css({ paddingLeft: ts(1) })}>{t("login.username")}</P>
<Input
autoComplete="username"
variant="big"
onChangeText={(value) => setUsername(value)}
/>
<P {...css({ paddingLeft: ts(1) })}>{t("login.email")}</P>
<Input autoComplete="email" variant="big" onChangeText={(value) => setEmail(value)} />
<P {...css({ paddingLeft: ts(1) })}>{t("login.email")}</P>
<Input autoComplete="email" variant="big" onChangeText={(value) => setEmail(value)} />
<P {...css({ paddingLeft: ts(1) })}>{t("login.password")}</P>
<PasswordInput
autoComplete="password-new"
variant="big"
onChangeText={(value) => setPassword(value)}
/>
<P {...css({ paddingLeft: ts(1) })}>{t("login.password")}</P>
<PasswordInput
autoComplete="password-new"
variant="big"
onChangeText={(value) => setPassword(value)}
/>
<P {...css({ paddingLeft: ts(1) })}>{t("login.confirm")}</P>
<PasswordInput
autoComplete="password-new"
variant="big"
onChangeText={(value) => setConfirm(value)}
/>
<P {...css({ paddingLeft: ts(1) })}>{t("login.confirm")}</P>
<PasswordInput
autoComplete="password-new"
variant="big"
onChangeText={(value) => setConfirm(value)}
/>
{password !== confirm && (
<P {...css({ color: (theme) => theme.colors.red })}>{t("login.password-no-match")}</P>
{password !== confirm && (
<P {...css({ color: (theme) => theme.colors.red })}>{t("login.password-no-match")}</P>
)}
{error && <P {...css({ color: (theme) => theme.colors.red })}>{error}</P>}
<Button
text={t("login.register")}
disabled={password !== confirm}
onPress={async () => {
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 && <P {...css({ color: (theme) => theme.colors.red })}>{error}</P>}
<Button
text={t("login.register")}
disabled={password !== confirm}
onPress={async () => {
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),
})}
/>
<P>
<Trans i18nKey="login.or-login">
Have an account already? <A href={{ pathname: "/login", query: { apiUrl } }}>Log in</A>.