Skip to content

Commit

Permalink
Merge pull request #141 from sbondCo/admin-users
Browse files Browse the repository at this point in the history
Admin users and json config
  • Loading branch information
IRHM authored Oct 3, 2023
2 parents a600aff + 1387aa3 commit e605c8e
Show file tree
Hide file tree
Showing 10 changed files with 399 additions and 85 deletions.
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

0 comments on commit e605c8e

Please sign in to comment.