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) # http route prefix (will listen to $KYOO_PREFIX/movie for example)
KYOO_PREFIX="" 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 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 # 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: # 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. /// Check if kyoo's setup is finished.
/// </summary> /// </summary>
public SetupStep SetupStatus { get; set; } 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 public class OidcInfo

View File

@ -26,6 +26,7 @@ using Kyoo.Abstractions.Models.Attributes;
using Kyoo.Abstractions.Models.Exceptions; using Kyoo.Abstractions.Models.Exceptions;
using Kyoo.Abstractions.Models.Permissions; using Kyoo.Abstractions.Models.Permissions;
using Kyoo.Abstractions.Models.Utils; using Kyoo.Abstractions.Models.Utils;
using Kyoo.Authentication.Attributes;
using Kyoo.Authentication.Models; using Kyoo.Authentication.Models;
using Kyoo.Authentication.Models.DTO; using Kyoo.Authentication.Models.DTO;
using Kyoo.Models; using Kyoo.Models;
@ -206,6 +207,7 @@ public class AuthApi(
[HttpPost("login")] [HttpPost("login")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
[DisableOnEnvVar("AUTHENTICATION_DISABLE_PASSWORD_LOGIN")]
public async Task<ActionResult<JwtToken>> Login([FromBody] LoginRequest request) public async Task<ActionResult<JwtToken>> Login([FromBody] LoginRequest request)
{ {
User? user = await users.GetOrDefault( User? user = await users.GetOrDefault(
@ -241,6 +243,7 @@ public class AuthApi(
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(RequestError))] [ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(RequestError))]
[DisableOnEnvVar("AUTHENTICATION_DISABLE_USER_REGISTRATION")]
public async Task<ActionResult<JwtToken>> Register([FromBody] RegisterRequest request) public async Task<ActionResult<JwtToken>> Register([FromBody] RegisterRequest request)
{ {
try try

View File

@ -23,6 +23,7 @@ using Kyoo.Abstractions.Models.Attributes;
using Kyoo.Authentication.Models; using Kyoo.Authentication.Models;
using Kyoo.Core.Controllers; using Kyoo.Core.Controllers;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using static Kyoo.Abstractions.Models.Utils.Constants; using static Kyoo.Abstractions.Models.Utils.Constants;
namespace Kyoo.Authentication.Views; namespace Kyoo.Authentication.Views;
@ -33,7 +34,8 @@ namespace Kyoo.Authentication.Views;
[ApiController] [ApiController]
[Route("info")] [Route("info")]
[ApiDefinition("Info", Group = UsersGroup)] [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() 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), .ToDictionary(x => x.Key, x => x.Value),
SetupStatus = await info.GetSetupStep(), 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. * Check if kyoo's setup is finished.
*/ */
setupStatus: z.nativeEnum(SetupStep), 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) => { .transform((x) => {
const baseUrl = Platform.OS === "web" ? x.publicUrl : "kyoo://"; 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/>. * 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 { A, Button, H1, Input, P, ts } from "@kyoo/primitives";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { Trans } from "react-i18next";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { useRouter } from "solito/router"; import { useRouter } from "solito/router";
import { percent, px, useYoshiki } from "yoshiki/native"; import { percent, px, useYoshiki } from "yoshiki/native";
@ -35,6 +34,11 @@ export const LoginPage: QueryPage<{ apiUrl?: string; error?: string }> = ({
apiUrl, apiUrl,
error: initialError, error: initialError,
}) => { }) => {
const { data } = useFetch({
path: ["info"],
parser: ServerInfoP,
});
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState<string | undefined>(initialError); const [error, setError] = useState<string | undefined>(initialError);
@ -53,7 +57,9 @@ export const LoginPage: QueryPage<{ apiUrl?: string; error?: string }> = ({
return ( return (
<FormPage apiUrl={apiUrl}> <FormPage apiUrl={apiUrl}>
<H1>{t("login.login")}</H1> <H1>{t("login.login")}</H1>
<OidcLogin apiUrl={apiUrl} /> <OidcLogin apiUrl={apiUrl} hideOr={!data?.passwordLoginEnabled} />
{data?.passwordLoginEnabled && (
<>
<P {...css({ paddingLeft: ts(1) })}>{t("login.username")}</P> <P {...css({ paddingLeft: ts(1) })}>{t("login.username")}</P>
<Input <Input
autoComplete="username" autoComplete="username"
@ -90,6 +96,8 @@ export const LoginPage: QueryPage<{ apiUrl?: string; error?: string }> = ({
mY: ts(3), mY: ts(3),
})} })}
/> />
</>
)}
<P> <P>
<Trans i18nKey="login.or-register"> <Trans i18nKey="login.or-register">
Dont have an account? <A href={{ pathname: "/register", query: { apiUrl } }}>Register</A> 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 { percent, rem, useYoshiki } from "yoshiki/native";
import { ErrorView } from "../errors"; import { ErrorView } from "../errors";
export const OidcLogin = ({ apiUrl }: { apiUrl?: string }) => { export const OidcLogin = ({ apiUrl, hideOr }: { apiUrl?: string; hideOr?: boolean }) => {
const { css } = useYoshiki(); const { css } = useYoshiki();
const { t } = useTranslation(); const { t } = useTranslation();
const { data, error } = useFetch({ options: { apiUrl }, ...OidcLogin.query() }); const { data, error } = useFetch({ options: { apiUrl }, ...OidcLogin.query() });
@ -76,6 +76,7 @@ export const OidcLogin = ({ apiUrl }: { apiUrl?: string }) => {
flexDirection: "row", flexDirection: "row",
width: percent(100), width: percent(100),
alignItems: "center", alignItems: "center",
display: hideOr ? "none" : undefined,
})} })}
> >
<HR {...css({ flexGrow: 1 })} /> <HR {...css({ flexGrow: 1 })} />

View File

@ -18,11 +18,10 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * 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 { A, Button, H1, Input, P, ts } from "@kyoo/primitives";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { Trans } from "react-i18next";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { useRouter } from "solito/router"; import { useRouter } from "solito/router";
import { percent, px, useYoshiki } from "yoshiki/native"; import { percent, px, useYoshiki } from "yoshiki/native";
@ -32,6 +31,11 @@ import { OidcLogin } from "./oidc";
import { PasswordInput } from "./password-input"; import { PasswordInput } from "./password-input";
export const RegisterPage: QueryPage<{ apiUrl?: string }> = ({ apiUrl }) => { export const RegisterPage: QueryPage<{ apiUrl?: string }> = ({ apiUrl }) => {
const { data } = useFetch({
path: ["info"],
parser: ServerInfoP,
});
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
@ -52,9 +56,15 @@ export const RegisterPage: QueryPage<{ apiUrl?: string }> = ({ apiUrl }) => {
return ( return (
<FormPage apiUrl={apiUrl}> <FormPage apiUrl={apiUrl}>
<H1>{t("login.register")}</H1> <H1>{t("login.register")}</H1>
<OidcLogin apiUrl={apiUrl} /> <OidcLogin apiUrl={apiUrl} hideOr={!data?.passwordLoginEnabled} />
{data?.registrationEnabled && (
<>
<P {...css({ paddingLeft: ts(1) })}>{t("login.username")}</P> <P {...css({ paddingLeft: ts(1) })}>{t("login.username")}</P>
<Input autoComplete="username" variant="big" onChangeText={(value) => setUsername(value)} /> <Input
autoComplete="username"
variant="big"
onChangeText={(value) => setUsername(value)}
/>
<P {...css({ paddingLeft: ts(1) })}>{t("login.email")}</P> <P {...css({ paddingLeft: ts(1) })}>{t("login.email")}</P>
<Input autoComplete="email" variant="big" onChangeText={(value) => setEmail(value)} /> <Input autoComplete="email" variant="big" onChangeText={(value) => setEmail(value)} />
@ -96,6 +106,8 @@ export const RegisterPage: QueryPage<{ apiUrl?: string }> = ({ apiUrl }) => {
mY: ts(3), mY: ts(3),
})} })}
/> />
</>
)}
<P> <P>
<Trans i18nKey="login.or-login"> <Trans i18nKey="login.or-login">
Have an account already? <A href={{ pathname: "/login", query: { apiUrl } }}>Log in</A>. Have an account already? <A href={{ pathname: "/login", query: { apiUrl } }}>Log in</A>.