Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Admin users and json config #141

Merged
merged 12 commits into from
Oct 3, 2023
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
"format": "prettier --write .",
"server": "cd ./server && MODE=DEV go run ."
},
"devDependencies": {
"@sveltejs/adapter-node": "^1.3.1",
Expand Down
25 changes: 0 additions & 25 deletions server/.env.example

This file was deleted.

66 changes: 58 additions & 8 deletions server/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ var (
JELLYFIN_USER UserType = 1
)

// User Perms
// iota auto increments for us so when adding new
// perms, add to bottom as to not change other perm
// values.
const (
PERM_NONE int = 1 << iota
PERM_ADMIN
PERM_REQUEST_CONTENT
)

// uniqueIndex applied between Username and UserType, so same usernames can exist, but only with different types.
// This is incase different users with same name from different services try to signup.
type User struct {
Expand All @@ -44,6 +54,7 @@ type User struct {
// Auth token from third party (jellyfin)
ThirdPartyAuth string `json:"-"`
Watched []Watched
Permissions int `gorm:"default:1" json:"-"`
// All user settings cols, in another struct for reusability
UserSettings
}
Expand All @@ -53,6 +64,14 @@ type UserSettings struct {
Private bool `gorm:"default:false" json:"private"`
}

// We use a separate struct for registration to avoid confusion
// and possible accidents where we allow a user to pass in a
// property from the main User struct that shouldn't be allowed.
type UserRegisterRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}

type JellyfinAuth struct {
Username string `json:"Username"`
Pw string `json:"Pw"`
Expand All @@ -73,6 +92,7 @@ type AuthResponse struct {
type AvailableAuthProvidersResponse struct {
AvailableAuthProviders []string `json:"available"`
SignupEnabled bool `json:"signupEnabled"`
IsInSetup bool `json:"isInSetup"`
}

type ArgonParams struct {
Expand Down Expand Up @@ -149,11 +169,12 @@ func AuthRequired(db *gorm.DB) gin.HandlerFunc {
}
}

func register(user *User, db *gorm.DB) (AuthResponse, error) {
if os.Getenv("SIGNUP_ENABLED") == "false" {
slog.Warn("Register called, but signing up is disabled via env variable.")
func register(ur *UserRegisterRequest, initialPerm int, db *gorm.DB) (AuthResponse, error) {
if !Config.SIGNUP_ENABLED {
slog.Warn("Register called, but signing up is disabled.")
return AuthResponse{}, errors.New("registering is disabled")
}
var user User = User{Username: ur.Username, Password: ur.Password}
slog.Info("A user is registering", "username", user.Username)
hash, err := hashPassword(user.Password, &ArgonParams{
memory: 64 * 1024,
Expand All @@ -169,6 +190,12 @@ func register(user *User, db *gorm.DB) (AuthResponse, error) {
// Update user obj to replace the plaintext pass with hash
user.Password = hash

// Update user permissions if an initial perm is passed in (1 is default)
if initialPerm != 0 && initialPerm != PERM_NONE {
slog.Info("User being registered has been given extra initial permissions", "initial_perm", initialPerm)
user.Permissions = initialPerm
}

res := db.Create(&user)
if res.Error != nil {
// If error is because unique contraint failed.. user already exists
Expand All @@ -187,14 +214,30 @@ func register(user *User, db *gorm.DB) (AuthResponse, error) {
return AuthResponse{}, errors.New("failed to get user id, try login")
}

token, err := signJWT(user)
token, err := signJWT(&user)
if err != nil {
slog.Error("Registration: Failed to sign new jwt", "error", err)
return AuthResponse{}, errors.New("failed to get auth token")
}
return AuthResponse{Token: token}, nil
}

func registerFirstUser(user *UserRegisterRequest, db *gorm.DB) (AuthResponse, error) {
// Ensure no users exist
var userCount int64
uresp := db.Model(&User{}).Count(&userCount)
if uresp.Error != nil {
slog.Error("registerFirstUser: User count query failed!", "error", uresp.Error)
return AuthResponse{}, errors.New("failed to query db for a count of users")
}
if userCount != 0 {
slog.Warn("registerFirstUser: registered users already exist.")
return AuthResponse{}, errors.New("first user already registered")
}
slog.Info("Registering first user.")
return register(user, PERM_ADMIN, db)
}

func login(user *User, db *gorm.DB) (AuthResponse, error) {
slog.Debug("A User Is Logging In", "username", user.Username)
dbUser := new(User)
Expand Down Expand Up @@ -223,13 +266,12 @@ func login(user *User, db *gorm.DB) (AuthResponse, error) {
}

func loginJellyfin(user *User, db *gorm.DB) (AuthResponse, error) {
jellyfinHost := os.Getenv("JELLYFIN_HOST")
if jellyfinHost == "" {
slog.Error("Request made to login via Jellyfin, but JELLYFIN_HOST environment variable is not set.")
if Config.JELLYFIN_HOST == "" {
slog.Error("Request made to login via Jellyfin, but JELLYFIN_HOST has not been configured.")
return AuthResponse{}, errors.New("jellyfin login not enabled")
}

base, err := url.Parse(jellyfinHost + "/Users/AuthenticateByName")
base, err := url.Parse(Config.JELLYFIN_HOST + "/Users/AuthenticateByName")
if err != nil {
slog.Error("Failed to parse AuthenticateByName api endpoint url", "error", err.Error())
return AuthResponse{}, errors.New("failed to parse api uri")
Expand Down Expand Up @@ -410,3 +452,11 @@ func decodeHash(encodedHash string) (p *ArgonParams, salt, hash []byte, err erro

return p, salt, hash, nil
}

func hasPermission(perms int, reqPerm int) bool {
// Admins have permission for everything.
if perms&PERM_ADMIN == PERM_ADMIN {
return true
}
return (perms & reqPerm) == reqPerm
}
108 changes: 108 additions & 0 deletions server/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package main

import (
"crypto/rand"
b64 "encoding/base64"
"encoding/json"
"log"
"log/slog"
"os"
)

type ServerConfig struct {
// Used to sign JWT tokens. Make sure to make
// it strong, just like a very long, complicated password.
JWT_SECRET string

// Optional: Point to your Jellyfin install
// to enable it as an auth provider.
JELLYFIN_HOST string `json:",omitempty"`

// Enable/disable signup functionality.
// Set to `false` to disable registering an account.
SIGNUP_ENABLED bool `json:",omitempty"`

// Optional: Provide your own TMDB API Key.
// If unprovided, the default Watcharr API key will be used.
TMDB_KEY string `json:",omitempty"`

// Enable/disable debug logging. Useful for when trying
// to figure out exactly what the server is doing at a point
// of failure.
// Set to `true` to enable.
DEBUG bool `json:",omitempty"`

// Optional: When not set we assume production, should only
// be set to DEV when developing the app.
// MODE string
}

var (
// Our server config.. set defaults here, then `readConfig`
// will overwrite if provided in watcharr.json cfg file.
Config = ServerConfig{
SIGNUP_ENABLED: true,
}
AvailableAuthProviders = []string{}
TMDBKey = "d047fa61d926371f277e7a83c9c4ff2c"
)

// Read config file
// Calls generateConfig if file doesn't exist
func readConfig() error {
cfg, err := os.Open("./data/watcharr.json")
if err != nil {
if os.IsNotExist(err) {
slog.Info("Config file doesn't exist... generating.")
if err = generateConfig(); err == nil {
return nil
}
}
return err
}
defer cfg.Close()
jsonParser := json.NewDecoder(cfg)
if err = jsonParser.Decode(&Config); err != nil {
return err
}
initFromConfig()
return nil
}

// Ensure required config is provided
// and initialize from the config if required (update vars)
func initFromConfig() error {
if Config.JWT_SECRET == "" {
log.Fatal("JWT_SECRET missing from config!")
}

if Config.JELLYFIN_HOST != "" {
slog.Info("Adding Jellyfin as an auth provider.")
AvailableAuthProviders = append(AvailableAuthProviders, "jellyfin")
}

if Config.TMDB_KEY != "" {
slog.Info("Default TMDBKey being overriden by TMDB_KEY from config.")
TMDBKey = Config.TMDB_KEY
}
return nil
}

// Generate new barebones watcharr.json config file.
// Currently only JWT_SECRET is required, so this method
// generates a secret.
func generateConfig() error {
key := make([]byte, 64)
_, err := rand.Read(key)
if err != nil {
return err
}
encKey := b64.StdEncoding.EncodeToString([]byte(key))
cfg := ServerConfig{JWT_SECRET: encKey}
barej, err := json.MarshalIndent(cfg, "", "\t")
if err != nil {
return err
}
Config = cfg
return os.WriteFile("./data/watcharr.json", barej, 0755)
}
15 changes: 6 additions & 9 deletions server/jellyfin.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"log/slog"
"net/http"
"net/url"
"os"
)

type JellyfinItemSearchResponse struct {
Expand All @@ -30,13 +29,12 @@ type JFContentFindResponse struct {
}

func jellyfinAPIRequest(method string, ep string, p map[string]string, username string, userToken string, resp interface{}) error {
jellyfinHost := os.Getenv("JELLYFIN_HOST")
if jellyfinHost == "" {
slog.Error("jellyfinAPIRequest: JELLYFIN_HOST environment variable is not set.")
if Config.JELLYFIN_HOST == "" {
slog.Error("jellyfinAPIRequest: JELLYFIN_HOST not configured.")
return errors.New("jellyfin not enabled")
}
slog.Debug("jellyfinAPIRequest", "endpoint", ep, "params", p)
base, err := url.Parse(jellyfinHost)
base, err := url.Parse(Config.JELLYFIN_HOST)
if err != nil {
return errors.New("failed to parse api uri")
}
Expand Down Expand Up @@ -99,9 +97,8 @@ func jellyfinContentFind(
contentName string,
contentTmdbId string,
) (JFContentFindResponse, error) {
jellyfinHost := os.Getenv("JELLYFIN_HOST")
if jellyfinHost == "" {
slog.Error("Request made to login via Jellyfin, but JELLYFIN_HOST environment variable is not set.")
if Config.JELLYFIN_HOST == "" {
slog.Error("Request made to login via Jellyfin, but JELLYFIN_HOST has not been configured.")
return JFContentFindResponse{}, errors.New("jellyfin login not enabled")
}
if userType != JELLYFIN_USER || userThirdPartyId == "" {
Expand Down Expand Up @@ -154,7 +151,7 @@ func jellyfinContentFind(
for _, i := range resp.Items {
if i.ProviderIds.Tmdb == contentTmdbId {
ret.HasContent = true
ret.Url = jellyfinHost + "/web/index.html#!/details?id=" + i.Id + "&serverId=" + i.ServerID
ret.Url = Config.JELLYFIN_HOST + "/web/index.html#!/details?id=" + i.Id + "&serverId=" + i.ServerID
}
}
return *ret, nil
Expand Down
Loading