mirror of
https://github.com/sbondCo/Watcharr.git
synced 2026-08-07 07:14:44 +00:00
Merge pull request #736 from sbondCo/proxy-auth-flow
Trusted header authentication
This commit is contained in:
+4
-1
@@ -25,10 +25,12 @@ import (
|
||||
|
||||
type UserType uint8
|
||||
|
||||
// Assume watcharr user if none of these...
|
||||
var (
|
||||
// Assume watcharr user if none of these...
|
||||
JELLYFIN_USER UserType = 1
|
||||
PLEX_USER UserType = 2
|
||||
// Registered via trusted header auth
|
||||
PROXY_USER UserType = 3
|
||||
)
|
||||
|
||||
// User Perms
|
||||
@@ -160,6 +162,7 @@ type AvailableAuthProvidersResponse struct {
|
||||
SignupEnabled bool `json:"signupEnabled"`
|
||||
IsInSetup bool `json:"isInSetup"`
|
||||
UseEmby bool `json:"useEmby"`
|
||||
HeaderAuthAutoLogin bool `json:"headerAuthAutoLogin"`
|
||||
}
|
||||
|
||||
type ArgonParams struct {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// auth_proxy contains the logic for Trusted Header Authentication/SSO.
|
||||
//
|
||||
// This code is inherently dangerous since we are implicitly trusting
|
||||
// a header for auth, so this should only be configured if you are
|
||||
// certain your watcharr instance is only available behind your proxy.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type TrustedHeaderAuthSetting struct {
|
||||
// Required: Should header auth be enabled?
|
||||
// This bool exists so header auth can be toggled
|
||||
// easily without having to remove configuration.
|
||||
// To be actually enabled, HEADER_NAME must also
|
||||
// be set.
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
// Required: What is the name of the trusted header
|
||||
// that will contain the logged in users username?
|
||||
HeaderName string `json:"headerName,omitempty"`
|
||||
// Should the frontend attempt auto login if
|
||||
// trusted header auth is enabled.
|
||||
AutoLogin bool `json:"autoLogin,omitempty"`
|
||||
// Where can we redirect the user to logout
|
||||
// of the auth service?
|
||||
LogoutUrl string `json:"logoutUrl,omitempty"`
|
||||
}
|
||||
|
||||
type TrustedHeaderAuthLogoutDetailsResponse struct {
|
||||
LogoutUrl string `json:"logoutUrl,omitempty"`
|
||||
}
|
||||
|
||||
// Is trusted header auth configured on this server?
|
||||
func trustedHeaderAuthIsEnabled() bool {
|
||||
return Config.HEADER_AUTH.Enabled && Config.HEADER_AUTH.HeaderName != ""
|
||||
}
|
||||
|
||||
func setTrustedHeaderAuthSetting(has TrustedHeaderAuthSetting) error {
|
||||
slog.Debug("setTrustedHeaderAuthSetting: Attempting to update to new provided value", "new_value", has)
|
||||
Config.HEADER_AUTH = has
|
||||
err := writeConfig()
|
||||
if err != nil {
|
||||
slog.Error("setTrustedHeaderAuthSetting: Failed to write updated config!", "error", err)
|
||||
return errors.New("failed to write config")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Gets proxy logout details.
|
||||
// Details are accessible to any user for the logout flow.
|
||||
// If proxy configured should be checked before using this.
|
||||
func getTrustedHeaderAuthLogoutDetails() *TrustedHeaderAuthLogoutDetailsResponse {
|
||||
return &TrustedHeaderAuthLogoutDetailsResponse{
|
||||
LogoutUrl: Config.HEADER_AUTH.LogoutUrl,
|
||||
}
|
||||
}
|
||||
|
||||
// Login via header sso
|
||||
func loginTrustedHeaderAuth(user *User, db *gorm.DB) (AuthResponse, error) {
|
||||
slog.Debug("loginTrustedHeaderAuth: A user is logging in", "username_from_header", user.Username)
|
||||
dbUser := new(User)
|
||||
res := db.Where("username = ? AND type = ?", user.Username, PROXY_USER).Take(&dbUser)
|
||||
if res.Error != nil {
|
||||
if errors.Is(res.Error, gorm.ErrRecordNotFound) {
|
||||
slog.Info("loginTrustedHeaderAuth: Creating new User from authentication header", "username_from_header", user.Username)
|
||||
// Record not found, so we should create the user (if configured to do so)
|
||||
// dbUser will be empty, so we can just reuse it for this purpose.
|
||||
dbUser.Username = user.Username
|
||||
dbUser.Type = PROXY_USER
|
||||
dbUser.Country = &Config.DEFAULT_COUNTRY
|
||||
|
||||
res = db.Create(&dbUser)
|
||||
if res.Error != nil {
|
||||
slog.Error("loginTrustedHeaderAuth: Failed to create new user in db", "error", res.Error)
|
||||
return AuthResponse{}, errors.New("failed to create new user")
|
||||
}
|
||||
} else {
|
||||
slog.Error("loginTrustedHeaderAuth: An error occurred when looking up user in db", "error", res.Error)
|
||||
return AuthResponse{}, errors.New("error locating user in db")
|
||||
}
|
||||
}
|
||||
token, err := signJWT(dbUser)
|
||||
if err != nil {
|
||||
slog.Error("loginTrustedHeaderAuth: Failed to sign new jwt", "error", err)
|
||||
return AuthResponse{}, errors.New("failed to get auth token")
|
||||
}
|
||||
return AuthResponse{Token: token}, nil
|
||||
}
|
||||
+39
-1
@@ -54,6 +54,10 @@ type ServerConfig struct {
|
||||
// Will be fetched automatically when PLEX_HOST is provided via web ui.
|
||||
PLEX_MACHINE_ID string `json:",omitempty"`
|
||||
|
||||
// Optional: Trusted header authentication configuration.
|
||||
// VERY DANGEROUS if access is not controlled correctly!
|
||||
HEADER_AUTH TrustedHeaderAuthSetting `json:",omitempty"`
|
||||
|
||||
SONARR []SonarrSettings `json:",omitempty"`
|
||||
RADARR []RadarrSettings `json:",omitempty"`
|
||||
TWITCH game.IGDB `json:",omitempty"`
|
||||
@@ -93,6 +97,35 @@ func (c *ServerConfig) GetSafe() ServerConfig {
|
||||
}
|
||||
}
|
||||
|
||||
type ServerConfigGetByName struct {
|
||||
Value any `json:"value"`
|
||||
}
|
||||
|
||||
// Get config item by name.
|
||||
func (c *ServerConfig) Get(s string) (ServerConfigGetByName, error) {
|
||||
switch s {
|
||||
case "DEFAULT_COUNTRY":
|
||||
return ServerConfigGetByName{Value: c.DEFAULT_COUNTRY}, nil
|
||||
case "JELLYFIN_HOST":
|
||||
return ServerConfigGetByName{Value: c.JELLYFIN_HOST}, nil
|
||||
case "USE_EMBY":
|
||||
return ServerConfigGetByName{Value: c.USE_EMBY}, nil
|
||||
case "SIGNUP_ENABLED":
|
||||
return ServerConfigGetByName{Value: c.SIGNUP_ENABLED}, nil
|
||||
case "TMDB_KEY":
|
||||
return ServerConfigGetByName{Value: c.TMDB_KEY}, nil
|
||||
case "PLEX_HOST":
|
||||
return ServerConfigGetByName{Value: c.PLEX_HOST}, nil
|
||||
case "PLEX_MACHINE_ID":
|
||||
return ServerConfigGetByName{Value: c.PLEX_MACHINE_ID}, nil
|
||||
case "HEADER_AUTH":
|
||||
return ServerConfigGetByName{Value: c.HEADER_AUTH}, nil
|
||||
case "DEBUG":
|
||||
return ServerConfigGetByName{Value: c.DEBUG}, nil
|
||||
}
|
||||
return ServerConfigGetByName{}, errors.New("invalid setting")
|
||||
}
|
||||
|
||||
var (
|
||||
// Our server config.. `readConfig` will overwrite from watcharr.json cfg file.
|
||||
Config = ServerConfig{}
|
||||
@@ -171,7 +204,12 @@ func updateConfig(k string, v any) error {
|
||||
} else {
|
||||
return errors.New("invalid setting")
|
||||
}
|
||||
return writeConfig()
|
||||
err := writeConfig()
|
||||
if err != nil {
|
||||
slog.Error("updateConfig: Failed to write updated config!", "error", err)
|
||||
return errors.New("failed to write config")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Write current Config to file
|
||||
|
||||
+89
-11
@@ -761,6 +761,28 @@ func (b *BaseRouter) addAuthRoutes() {
|
||||
c.Status(400)
|
||||
})
|
||||
|
||||
// Proxy Login
|
||||
auth.POST("/proxy", func(c *gin.Context) {
|
||||
var user User
|
||||
if !trustedHeaderAuthIsEnabled() {
|
||||
slog.Error("ProxyLogin: SSO has not been configured.")
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: "proxy authentication is disabled"})
|
||||
return
|
||||
}
|
||||
user.Username = c.GetHeader(Config.HEADER_AUTH.HeaderName)
|
||||
if user.Username == "" {
|
||||
slog.Error("ProxyLogin: Authentication header is missing.")
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: "authentication header missing"})
|
||||
return
|
||||
}
|
||||
response, err := loginTrustedHeaderAuth(&user, b.db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
// Register
|
||||
auth.POST("/register", func(c *gin.Context) {
|
||||
var user UserRegisterRequest
|
||||
@@ -778,24 +800,45 @@ func (b *BaseRouter) addAuthRoutes() {
|
||||
|
||||
// Get available auth providers
|
||||
auth.GET("/available", func(c *gin.Context) {
|
||||
availableAuthProviders := []string{}
|
||||
if Config.JELLYFIN_HOST != "" {
|
||||
availableAuthProviders = append(availableAuthProviders, "jellyfin")
|
||||
}
|
||||
if Config.PLEX_HOST != "" && Config.PLEX_MACHINE_ID != "" {
|
||||
availableAuthProviders = append(availableAuthProviders, "plex")
|
||||
}
|
||||
c.JSON(http.StatusOK, &AvailableAuthProvidersResponse{
|
||||
AvailableAuthProviders: availableAuthProviders,
|
||||
resp := &AvailableAuthProvidersResponse{
|
||||
AvailableAuthProviders: []string{},
|
||||
SignupEnabled: Config.SIGNUP_ENABLED,
|
||||
IsInSetup: ServerInSetup,
|
||||
UseEmby: Config.USE_EMBY,
|
||||
})
|
||||
}
|
||||
if Config.JELLYFIN_HOST != "" {
|
||||
resp.AvailableAuthProviders = append(resp.AvailableAuthProviders, "jellyfin")
|
||||
}
|
||||
if Config.PLEX_HOST != "" && Config.PLEX_MACHINE_ID != "" {
|
||||
resp.AvailableAuthProviders = append(resp.AvailableAuthProviders, "plex")
|
||||
}
|
||||
if trustedHeaderAuthIsEnabled() {
|
||||
resp.AvailableAuthProviders = append(resp.AvailableAuthProviders, "header")
|
||||
resp.HeaderAuthAutoLogin = Config.HEADER_AUTH.AutoLogin
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
})
|
||||
|
||||
// IMPORTANT: Routes below here must be authenticated.
|
||||
auth.Use(AuthRequired(nil))
|
||||
{
|
||||
// Request details for logout process for proxy users.
|
||||
// Any proxy user can request this for logout.
|
||||
auth.GET("/proxy_logout_details", func(c *gin.Context) {
|
||||
if !trustedHeaderAuthIsEnabled() {
|
||||
slog.Error("GetProxy: SSO has not been configured.")
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: "proxy authentication is disabled"})
|
||||
return
|
||||
}
|
||||
userType := c.MustGet("userType").(UserType)
|
||||
if userType != PROXY_USER {
|
||||
slog.Error("GetProxy: Non proxy user attempted to fetch proxy logout details.")
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: "you are not a proxy user"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, getTrustedHeaderAuthLogoutDetails())
|
||||
})
|
||||
|
||||
// Request admin token
|
||||
auth.GET("/admin_token", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
@@ -1116,18 +1159,53 @@ func (b *BaseRouter) addServerRoutes() {
|
||||
|
||||
// Get server config (minus very sensitive fields, like JWT_SECRET)
|
||||
server.GET("/config", func(c *gin.Context) {
|
||||
// s should be provided when asking for the value of just one setting.
|
||||
s := c.Query("s")
|
||||
if s != "" {
|
||||
val, err := Config.Get(s)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, val)
|
||||
return
|
||||
}
|
||||
// Return new ServerConfig with only the fields we want to show in settings ui
|
||||
c.JSON(http.StatusOK, Config.GetSafe())
|
||||
})
|
||||
|
||||
// Update config
|
||||
server.POST("/config", func(c *gin.Context) {
|
||||
// If query param `s` provided, handle specific setting.
|
||||
// In this case, request body should be new setting value.
|
||||
s := c.Query("s")
|
||||
if s != "" {
|
||||
switch s {
|
||||
case "HEADER_AUTH":
|
||||
var ur TrustedHeaderAuthSetting
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
err = setTrustedHeaderAuthSetting(ur)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: "unsupported setting"})
|
||||
return
|
||||
}
|
||||
// No `s` param.. handle normally with `updateConfig` func.
|
||||
var ur KeyValueRequest
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
err := updateConfig(ur.Key, ur.Value)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
|
||||
@@ -447,6 +447,12 @@
|
||||
d="M490.18 181.4l-44.13-44.13a20 20 0 00-27-1 30.81 30.81 0 01-41.68-1.6 30.81 30.81 0 01-1.6-41.67 20 20 0 00-1-27L330.6 21.82a19.91 19.91 0 00-28.13 0l-70.35 70.34a39.87 39.87 0 00-9.57 15.5 7.71 7.71 0 01-4.83 4.83 39.78 39.78 0 00-15.5 9.58l-180.4 180.4a19.91 19.91 0 000 28.13L66 374.73a20 20 0 0027 1 30.69 30.69 0 0143.28 43.28 20 20 0 001 27l44.13 44.13a19.91 19.91 0 0028.13 0l180.4-180.4a39.82 39.82 0 009.58-15.49 7.69 7.69 0 014.84-4.84 39.84 39.84 0 0015.49-9.57l70.34-70.35a19.91 19.91 0 00-.01-28.09zm-228.37-29.65a16 16 0 01-22.63 0l-11.51-11.51a16 16 0 0122.63-22.62l11.51 11.5a16 16 0 010 22.63zm44 44a16 16 0 01-22.62 0l-11-11a16 16 0 1122.63-22.63l11 11a16 16 0 01.01 22.66zm44 44a16 16 0 01-22.63 0l-11-11a16 16 0 0122.63-22.62l11 11a16 16 0 01.05 22.67zm44.43 44.54a16 16 0 01-22.63 0l-11.44-11.5a16 16 0 1122.68-22.57l11.45 11.49a16 16 0 01-.01 22.63z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "lock-closed"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M368 192h-16v-80a96 96 0 10-192 0v80h-16a64.07 64.07 0 00-64 64v176a64.07 64.07 0 0064 64h224a64.07 64.07 0 0064-64V256a64.07 64.07 0 00-64-64zm-48 0H192v-80a64 64 0 11128 0z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
export let title: string;
|
||||
export let desc: string;
|
||||
export let type: "warn";
|
||||
</script>
|
||||
|
||||
<div class={type}>
|
||||
<h4 class="norm">{title}</h4>
|
||||
<p>{desc}</p>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
|
||||
&.warn {
|
||||
background-color: $warn;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import Modal from "../Modal.svelte";
|
||||
import axios from "axios";
|
||||
import type { TrustedHeaderAuthLogoutDetailsResponse } from "@/types";
|
||||
import Spinner from "../Spinner.svelte";
|
||||
import { clearWatcharrData } from ".";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
export let onClose: () => void;
|
||||
|
||||
let loadingBtns = true;
|
||||
let logoutUrl: string | undefined = undefined;
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const r = await axios.get<TrustedHeaderAuthLogoutDetailsResponse>(
|
||||
"/auth/proxy_logout_details"
|
||||
);
|
||||
logoutUrl = r?.data?.logoutUrl;
|
||||
if (!logoutUrl?.toLowerCase()?.startsWith("http")) {
|
||||
// If no protocol in logoutUrl, set https
|
||||
logoutUrl = `https://${logoutUrl}`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to get proxy logout details!", err);
|
||||
}
|
||||
loadingBtns = false;
|
||||
});
|
||||
|
||||
function logout() {
|
||||
clearWatcharrData();
|
||||
goto("/login?noAuto=1");
|
||||
}
|
||||
|
||||
function proxyLogout() {
|
||||
if (!logoutUrl) {
|
||||
console.error("proxyLogout: Not supported without a configured logoutUrl.");
|
||||
return;
|
||||
}
|
||||
clearWatcharrData();
|
||||
window.location.replace(logoutUrl);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
title="Logout"
|
||||
desc="You logged in via single sign-on. Did you click log out by mistake?"
|
||||
{onClose}
|
||||
>
|
||||
{#if loadingBtns}
|
||||
<Spinner />
|
||||
{:else}
|
||||
<div>
|
||||
<p>
|
||||
{#if logoutUrl}
|
||||
This is likely what you want: <b
|
||||
>Fully logout of your single sign-on service and Watcharr</b
|
||||
>.
|
||||
{:else}
|
||||
Single sign-on logout has not been configured on this server. If you are not the server
|
||||
operator, let them know!
|
||||
{/if}
|
||||
</p>
|
||||
<button disabled={!logoutUrl} on:click={proxyLogout}>Log out of Single Sign-On Service</button
|
||||
>
|
||||
<p>
|
||||
Logging out of Watcharr will clear your local credentials and data, but <b
|
||||
>you will still be logged in to your single sign-on service</b
|
||||
>! Do <b>not</b> do this on a public machine and assume you are logged out, this account could
|
||||
still be accessible.
|
||||
</p>
|
||||
<button on:click={logout}>Log out of Watcharr</button>
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
|
||||
p {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
button:first-of-type {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,14 @@
|
||||
import { clearAllStores } from "@/store";
|
||||
|
||||
/**
|
||||
* Helper to clear local data in client for logout
|
||||
* process. Logic is here so it's not duplciated,
|
||||
* this should help avoid forgetting to copy any
|
||||
* new logic to other places, which could break stuff.
|
||||
* Since this is reusable, technically unrelated logic
|
||||
* should not be included here (eg: redirecting to /login).
|
||||
*/
|
||||
export function clearWatcharrData() {
|
||||
localStorage.removeItem("token");
|
||||
clearAllStores();
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
import PageError from "@/lib/PageError.svelte";
|
||||
import Spinner from "@/lib/Spinner.svelte";
|
||||
import tooltip from "@/lib/actions/tooltip";
|
||||
import { clearWatcharrData } from "@/lib/logout";
|
||||
import ProxyUserLogoutModal from "@/lib/logout/ProxyUserLogoutModal.svelte";
|
||||
import DetailedMenu from "@/lib/nav/DetailedMenu.svelte";
|
||||
import FilterMenu from "@/lib/nav/FilterMenu.svelte";
|
||||
import FollowingMenu from "@/lib/nav/FollowingMenu.svelte";
|
||||
@@ -25,7 +27,7 @@
|
||||
userSettings,
|
||||
watchedList
|
||||
} from "@/store";
|
||||
import { UserPermission } from "@/types";
|
||||
import { UserPermission, UserType } from "@/types";
|
||||
import axios from "axios";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
@@ -38,6 +40,7 @@
|
||||
let followingMenuShown = false;
|
||||
let detailedMenuShown = false;
|
||||
let tagMenuShown = false;
|
||||
let proxyUserLogoutShown = false;
|
||||
|
||||
$: settings = $userSettings;
|
||||
$: user = $userInfo;
|
||||
@@ -91,8 +94,12 @@
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem("token");
|
||||
clearAllStores();
|
||||
if (user?.type === UserType.Proxy) {
|
||||
// Proxy users logout flow is different.
|
||||
proxyUserLogoutShown = true;
|
||||
return;
|
||||
}
|
||||
clearWatcharrData();
|
||||
goto("/login");
|
||||
}
|
||||
|
||||
@@ -365,6 +372,9 @@
|
||||
{/if}
|
||||
{/if}
|
||||
<button class="plain" on:click={() => logout()}>Logout</button>
|
||||
{#if proxyUserLogoutShown}
|
||||
<ProxyUserLogoutModal onClose={() => (proxyUserLogoutShown = false)} />
|
||||
{/if}
|
||||
<!-- svelte-ignore missing-declaration -->
|
||||
<span>v{__WATCHARR_VERSION__}</span>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { goto } from "$app/navigation";
|
||||
import axios from "axios";
|
||||
import { baseURL } from "@/lib/util/api";
|
||||
import { notify } from "@/lib/util/notify";
|
||||
import { clearWatcharrData } from "@/lib/logout";
|
||||
|
||||
axios.interceptors.request.use(
|
||||
(config) => {
|
||||
@@ -38,7 +39,7 @@ axios.interceptors.response.use(
|
||||
if (error.response?.status === 401) {
|
||||
console.error("Recieved 401 response, going to login.");
|
||||
notify({ text: "Request Authorization Failed!", type: "error" });
|
||||
localStorage.removeItem("token");
|
||||
clearWatcharrData();
|
||||
goto("/login?again=1");
|
||||
}
|
||||
return Promise.reject(error);
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
import TwitchModal from "./modals/TwitchModal.svelte";
|
||||
import RegionDropDown from "@/lib/RegionDropDown.svelte";
|
||||
import TaskScheduleModal from "./modals/TaskScheduleModal.svelte";
|
||||
import TrustedHeaderAuthModal from "./modals/TrustedHeaderAuthModal.svelte";
|
||||
|
||||
let serverConfig: ServerConfig;
|
||||
let sonarrModalOpen = false;
|
||||
@@ -34,6 +35,7 @@
|
||||
let radarrModalEditing = false;
|
||||
let twitchModalOpen = false;
|
||||
let taskScheduleModalOpen = false;
|
||||
let headerSSOModalOpen = false;
|
||||
// Disabled vars for disabling inputs until api request completes
|
||||
let signupDisabled = false;
|
||||
let debugDisabled = false;
|
||||
@@ -226,7 +228,7 @@
|
||||
disabled={tmdbkDisabled}
|
||||
/>
|
||||
</Setting>
|
||||
<Setting title="Signup" desc="Allow signing up with web ui" row>
|
||||
<Setting title="Signup" desc="Allow signing up with Watcharr credentials." row>
|
||||
<Checkbox
|
||||
name="SIGNUP_ENABLED"
|
||||
disabled={signupDisabled}
|
||||
@@ -239,7 +241,7 @@
|
||||
}}
|
||||
/>
|
||||
</Setting>
|
||||
<Setting title="Debug" desc="Enable debug logging" row>
|
||||
<Setting title="Debug" desc="Enable debug logging." row>
|
||||
<Checkbox
|
||||
name="DEBUG"
|
||||
disabled={debugDisabled}
|
||||
@@ -265,6 +267,20 @@
|
||||
{#if taskScheduleModalOpen}
|
||||
<TaskScheduleModal onClose={() => (taskScheduleModalOpen = false)}></TaskScheduleModal>
|
||||
{/if}
|
||||
<Setting>
|
||||
<SettingButton
|
||||
title="Trusted Header Authentication"
|
||||
desc="Configure trusted header single sign-on."
|
||||
icon={"arrow"}
|
||||
onClick={() => {
|
||||
headerSSOModalOpen = true;
|
||||
}}
|
||||
/>
|
||||
</Setting>
|
||||
{#if headerSSOModalOpen}
|
||||
<TrustedHeaderAuthModal onClose={() => (headerSSOModalOpen = false)}
|
||||
></TrustedHeaderAuthModal>
|
||||
{/if}
|
||||
<div>
|
||||
<h3>Services</h3>
|
||||
<h5 class="norm">
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
import Checkbox from "@/lib/Checkbox.svelte";
|
||||
import Modal from "@/lib/Modal.svelte";
|
||||
import Notice from "@/lib/Notice.svelte";
|
||||
import Spinner from "@/lib/Spinner.svelte";
|
||||
import Setting from "@/lib/settings/Setting.svelte";
|
||||
import SettingsList from "@/lib/settings/SettingsList.svelte";
|
||||
import { notify } from "@/lib/util/notify";
|
||||
import type { ServerConfigByName, TrustedHeaderAuthSetting } from "@/types";
|
||||
import axios from "axios";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
export let onClose: () => void;
|
||||
|
||||
let headerCfg: TrustedHeaderAuthSetting = {
|
||||
enabled: false,
|
||||
headerName: ""
|
||||
};
|
||||
let formDisabled = false;
|
||||
let loadingCfg = false;
|
||||
let error = "";
|
||||
|
||||
async function getHeaderCfg() {
|
||||
try {
|
||||
loadingCfg = true;
|
||||
const res = await axios.get<ServerConfigByName<TrustedHeaderAuthSetting>>("/server/config", {
|
||||
params: { s: "HEADER_AUTH" }
|
||||
});
|
||||
headerCfg = res.data.value;
|
||||
loadingCfg = false;
|
||||
} catch (err) {
|
||||
console.error("getHeaderCfg failed!", err);
|
||||
notify({ type: "error", text: "Failed to get configuration from server.", time: 6000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const nid = notify({
|
||||
type: "loading",
|
||||
text: "Saving.."
|
||||
});
|
||||
try {
|
||||
await axios.post("/server/config", headerCfg, { params: { s: "HEADER_AUTH" } });
|
||||
notify({
|
||||
id: nid,
|
||||
type: "success",
|
||||
text: "Changes saved!"
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("save failed!", err);
|
||||
notify({
|
||||
id: nid,
|
||||
type: "error",
|
||||
text: "Failed to save config. Please try again!",
|
||||
time: 6000
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
getHeaderCfg();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
title="Trusted Header Authentication"
|
||||
desc="Configure trusted header single sign-on."
|
||||
{onClose}
|
||||
>
|
||||
{#if error}
|
||||
<span class="error">{error}!</span>
|
||||
{/if}
|
||||
{#if loadingCfg}
|
||||
<Spinner />
|
||||
{:else}
|
||||
<SettingsList>
|
||||
<Notice
|
||||
title="This is dangerous!"
|
||||
desc="If setup incorrectly, the authorization module for your server could be easily comprimised. If configured, please ensure your Watcharr instance is only available through your proxy and not available directly."
|
||||
type="warn"
|
||||
/>
|
||||
<Setting title="Header Name" desc="Name of the header used for authentication.">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="X-User"
|
||||
on:blur={() => {}}
|
||||
disabled={formDisabled}
|
||||
bind:value={headerCfg.headerName}
|
||||
/>
|
||||
</Setting>
|
||||
<Setting title="Logout URL" desc="Where can we redirect so that the user can logout?">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://auth.example.com/logout"
|
||||
on:blur={() => {}}
|
||||
disabled={formDisabled}
|
||||
bind:value={headerCfg.logoutUrl}
|
||||
/>
|
||||
</Setting>
|
||||
<Setting
|
||||
title="Auto Login"
|
||||
desc="Should we auto login users as soon as they reach our login page?"
|
||||
row
|
||||
>
|
||||
<Checkbox
|
||||
name="HeaderAuthAutoLogin"
|
||||
disabled={formDisabled}
|
||||
bind:value={headerCfg.autoLogin}
|
||||
/>
|
||||
</Setting>
|
||||
<Setting
|
||||
title="Enabled"
|
||||
desc="Enable Trusted Header Single Sign-On? Toggle on to activate above configuration."
|
||||
row
|
||||
>
|
||||
<Checkbox name="HeaderAuthEnabled" disabled={formDisabled} bind:value={headerCfg.enabled} />
|
||||
</Setting>
|
||||
<div class="btns">
|
||||
<button on:click={() => save()}>Save</button>
|
||||
</div>
|
||||
</SettingsList>
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<style lang="scss">
|
||||
.btns {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 10px;
|
||||
|
||||
button {
|
||||
width: max-content;
|
||||
padding-left: 15px;
|
||||
padding-right: 15px;
|
||||
|
||||
&:nth-child(1) {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background-color: rgb(221, 48, 48);
|
||||
text-transform: capitalize;
|
||||
color: white;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
</style>
|
||||
@@ -10,8 +10,11 @@
|
||||
let error: string;
|
||||
let login = true;
|
||||
let availableProviders: string[] = [];
|
||||
let apHeader = false;
|
||||
let apPlex = false;
|
||||
let signupEnabled = true;
|
||||
let useEmby = false;
|
||||
let noAuto = false;
|
||||
|
||||
onMount(() => {
|
||||
if (localStorage.getItem("token")) {
|
||||
@@ -25,8 +28,14 @@
|
||||
goto("/setup");
|
||||
}
|
||||
availableProviders = r.data.available;
|
||||
apHeader = availableProviders?.includes("header");
|
||||
apPlex = availableProviders?.includes("plex");
|
||||
signupEnabled = r.data.signupEnabled;
|
||||
useEmby = r.data.useEmby;
|
||||
if (r.data.headerAuthAutoLogin && !noAuto) {
|
||||
console.log("handling headerAuthAutoLogin.. calling proxyLogin automatically now.");
|
||||
proxyLogin(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -35,6 +44,10 @@
|
||||
if (!error && $page.url.searchParams.get("again")) {
|
||||
error = "Please Login Again";
|
||||
}
|
||||
if ($page.url.searchParams.get("noAuto") == "1") {
|
||||
console.info("login: Found noAuto param.. auto logins should be disabled now.");
|
||||
noAuto = true;
|
||||
}
|
||||
});
|
||||
|
||||
function handleLogin(ev: SubmitEvent) {
|
||||
@@ -121,6 +134,32 @@
|
||||
error = "Plex login failed";
|
||||
}
|
||||
}
|
||||
|
||||
function proxyLogin(auto = false) {
|
||||
const nid = notify({ text: "Logging in", type: "loading" });
|
||||
noAuthAxios
|
||||
.post(`/auth/proxy`)
|
||||
.then((resp) => {
|
||||
if (resp.data?.token) {
|
||||
console.log("Received token... logging in.");
|
||||
localStorage.setItem("token", resp.data.token);
|
||||
goto("/");
|
||||
notify({ id: nid, text: `Welcome!`, type: "success" });
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.response) {
|
||||
error = err.response.data.error;
|
||||
} else {
|
||||
error = err.message;
|
||||
}
|
||||
if (auto) {
|
||||
notify({ id: nid, text: `Automatic SSO Login Failed!`, type: "error" });
|
||||
} else {
|
||||
unNotify(nid);
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
@@ -164,20 +203,36 @@
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{#if availableProviders?.findIndex((provider) => provider == "plex") > -1}
|
||||
{#if apHeader || apPlex}
|
||||
<p style="font-weight: bold; font-size: 14px;">or</p>
|
||||
<div class="login-btns">
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => {
|
||||
plexLogin();
|
||||
}}
|
||||
name="plex"
|
||||
class="plex other"
|
||||
>
|
||||
<Icon i="plex" wh={18} />Continue with Plex
|
||||
</button>
|
||||
</div>
|
||||
{#if apHeader}
|
||||
<div class="login-btns">
|
||||
<button
|
||||
type="button"
|
||||
name="proxy"
|
||||
class="proxy other"
|
||||
on:click={() => {
|
||||
proxyLogin();
|
||||
}}
|
||||
>
|
||||
<Icon i="lock-closed" wh={18} />Continue with Single Sign-On
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if apPlex}
|
||||
<div class="login-btns">
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => {
|
||||
plexLogin();
|
||||
}}
|
||||
name="plex"
|
||||
class="plex other"
|
||||
>
|
||||
<Icon i="plex" wh={18} />Continue with Plex
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="login-btns">
|
||||
@@ -238,6 +293,11 @@
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
|
||||
/* Hardcoded point for when main watcharr/jellyfin btns break. */
|
||||
@media screen and (max-width: 320px) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
|
||||
+20
-2
@@ -51,7 +51,8 @@ export type Icon =
|
||||
| "unpin"
|
||||
| "sparkles"
|
||||
| "tag"
|
||||
| "ticket";
|
||||
| "ticket"
|
||||
| "lock-closed";
|
||||
|
||||
export type Theme = "light" | "dark";
|
||||
|
||||
@@ -68,7 +69,8 @@ export type PosterExtraDetails = {
|
||||
export enum UserType {
|
||||
// Assume watcharr user if none of these...
|
||||
Jellyfin = 1,
|
||||
Plex = 2
|
||||
Plex = 2,
|
||||
Proxy = 3
|
||||
}
|
||||
|
||||
interface dbModel {
|
||||
@@ -258,6 +260,7 @@ export interface AvailableAuthProviders {
|
||||
signupEnabled: boolean;
|
||||
isInSetup: boolean;
|
||||
useEmby: boolean;
|
||||
headerAuthAutoLogin: boolean;
|
||||
}
|
||||
|
||||
export interface TokenClaims {
|
||||
@@ -933,6 +936,10 @@ export interface ServerConfig {
|
||||
DEBUG: boolean;
|
||||
}
|
||||
|
||||
export interface ServerConfigByName<T> {
|
||||
value: T;
|
||||
}
|
||||
|
||||
export interface SonarrSettings {
|
||||
name: string;
|
||||
host?: string;
|
||||
@@ -957,6 +964,17 @@ export interface TwitchSettings {
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
export interface TrustedHeaderAuthSetting {
|
||||
enabled: boolean;
|
||||
headerName: string;
|
||||
autoLogin?: boolean;
|
||||
logoutUrl?: string;
|
||||
}
|
||||
|
||||
export interface TrustedHeaderAuthLogoutDetailsResponse {
|
||||
logoutUrl?: string;
|
||||
}
|
||||
|
||||
export interface DropDownItem {
|
||||
id: number | string;
|
||||
value: string;
|
||||
|
||||
@@ -40,6 +40,7 @@ $poster-rating-color: var(--poster-rating-color);
|
||||
$poster-extra-detail-bg-color: rgba(46, 46, 46, 0.5);
|
||||
// Bg col of elements with image behind with mix-blend-mode: multiply where we want a little of image to come through.
|
||||
$img-blend-multiply-bg-col: var(--img-blend-multiply-bg-col);
|
||||
$warn: #f38755;
|
||||
$error: #f3555a;
|
||||
$success: #28a745;
|
||||
$success-hover: #1e7e34;
|
||||
|
||||
Reference in New Issue
Block a user