Skip to content

Commit

Permalink
Replace basic auth with cookie authentication (#440)
Browse files Browse the repository at this point in the history
* Add logout functionality and button
* Make session age configurable
  • Loading branch information
WithoutPants authored Apr 8, 2020
1 parent b3e8d1e commit 15e7756
Show file tree
Hide file tree
Showing 73 changed files with 12,297 additions and 49 deletions.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ require (
github.com/go-chi/chi v4.0.2+incompatible
github.com/gobuffalo/packr/v2 v2.0.2
github.com/golang-migrate/migrate/v4 v4.3.1
github.com/gorilla/sessions v1.2.0
github.com/gorilla/websocket v1.4.0
github.com/h2non/filetype v1.0.8
// this is required for generate
Expand Down
3 changes: 3 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -353,9 +353,12 @@ github.com/gorilla/mux v1.6.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2z
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.7.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/pat v0.0.0-20180118222023-199c85a7f6d1/go.mod h1:YeAe0gNeiNT5hoiZRI4yiOky6jVdNvfO2N6Kav/HmxY=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.1.2/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
github.com/gorilla/sessions v1.2.0 h1:S7P+1Hm5V/AT9cjEcUD5uDaQSX0OE577aCXgoaKpYbQ=
github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.2.0 h1:VJtLvh6VQym50czpZzx07z/kw9EgAxI3x1ZB8taTMQQ=
github.com/gorilla/websocket v1.2.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
Expand Down
1 change: 1 addition & 0 deletions graphql/documents/data/config.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ fragment ConfigGeneralData on ConfigGeneralResult {
maxStreamingTranscodeSize
username
password
maxSessionAge
logFile
logOut
logLevel
Expand Down
4 changes: 4 additions & 0 deletions graphql/schema/types/config.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ input ConfigGeneralInput {
username: String
"""Password"""
password: String
"""Maximum session cookie age"""
maxSessionAge: Int
"""Name of the log file"""
logFile: String
"""Whether to also output to stderr"""
Expand Down Expand Up @@ -51,6 +53,8 @@ type ConfigGeneralResult {
username: String!
"""Password"""
password: String!
"""Maximum session cookie age"""
maxSessionAge: Int!
"""Name of the log file"""
logFile: String
"""Whether to also output to stderr"""
Expand Down
1 change: 1 addition & 0 deletions pkg/api/context_keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ const (
sceneKey key = 2
studioKey key = 3
movieKey key = 4
ContextUser key = 5
)
4 changes: 4 additions & 0 deletions pkg/api/resolver_mutation_configure.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input models.Co
}
}

if input.MaxSessionAge != nil {
config.Set(config.MaxSessionAge, *input.MaxSessionAge)
}

if input.LogFile != nil {
config.Set(config.LogFile, input.LogFile)
}
Expand Down
1 change: 1 addition & 0 deletions pkg/api/resolver_query_configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ func makeConfigGeneralResult() *models.ConfigGeneralResult {
MaxStreamingTranscodeSize: &maxStreamingTranscodeSize,
Username: config.GetUsername(),
Password: config.GetPasswordHash(),
MaxSessionAge: config.GetMaxSessionAge(),
LogFile: &logFile,
LogOut: config.GetLogOut(),
LogLevel: config.GetLogLevel(),
Expand Down
72 changes: 60 additions & 12 deletions pkg/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"io/ioutil"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
Expand Down Expand Up @@ -37,38 +38,69 @@ var uiBox *packr.Box

//var legacyUiBox *packr.Box
var setupUIBox *packr.Box
var loginUIBox *packr.Box

func allowUnauthenticated(r *http.Request) bool {
return strings.HasPrefix(r.URL.Path, "/login") || r.URL.Path == "/css"
}

func authenticateHandler() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// only do this if credentials have been configured
if !config.HasCredentials() {
next.ServeHTTP(w, r)
ctx := r.Context()

// translate api key into current user, if present
userID := ""
var err error

// handle session
userID, err = getSessionUserID(w, r)

if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}

authUser, authPW, ok := r.BasicAuth()

if !ok || !config.ValidateCredentials(authUser, authPW) {
unauthorized(w)
// handle redirect if no user and user is required
if userID == "" && config.HasCredentials() && !allowUnauthenticated(r) {
// always allow

// if we don't have a userID, then redirect
// if graphql was requested, we just return a forbidden error
if r.URL.Path == "/graphql" {
w.Header().Add("WWW-Authenticate", `FormBased`)
w.WriteHeader(http.StatusUnauthorized)
return
}

// otherwise redirect to the login page
u := url.URL{
Path: "/login",
}
q := u.Query()
q.Set(returnURLParam, r.URL.Path)
u.RawQuery = q.Encode()
http.Redirect(w, r, u.String(), http.StatusFound)
return
}

ctx = context.WithValue(ctx, ContextUser, userID)

r = r.WithContext(ctx)

next.ServeHTTP(w, r)
})
}
}

func unauthorized(w http.ResponseWriter) {
w.Header().Add("WWW-Authenticate", `Basic realm=\"Stash\"`)
w.WriteHeader(http.StatusUnauthorized)
}

func Start() {
uiBox = packr.New("UI Box", "../../ui/v2.5/build")
//legacyUiBox = packr.New("UI Box", "../../ui/v1/dist/stash-frontend")
setupUIBox = packr.New("Setup UI Box", "../../ui/setup")
loginUIBox = packr.New("Login UI Box", "../../ui/login")

initSessionStore()
initialiseImages()

r := chi.NewRouter()
Expand Down Expand Up @@ -107,6 +139,12 @@ func Start() {
r.Handle("/graphql", gqlHandler)
r.Handle("/playground", handler.Playground("GraphQL playground", "/graphql"))

// session handlers
r.Post("/login", handleLogin)
r.Get("/logout", handleLogout)

r.Get("/login", getLoginHandler)

r.Mount("/gallery", galleryRoutes{}.Routes())
r.Mount("/performer", performerRoutes{}.Routes())
r.Mount("/scene", sceneRoutes{}.Routes())
Expand Down Expand Up @@ -144,6 +182,16 @@ func Start() {
http.FileServer(setupUIBox).ServeHTTP(w, r)
}
})
r.HandleFunc("/login*", func(w http.ResponseWriter, r *http.Request) {
ext := path.Ext(r.URL.Path)
if ext == ".html" || ext == "" {
data, _ := loginUIBox.Find("login.html")
_, _ = w.Write(data)
} else {
r.URL.Path = strings.Replace(r.URL.Path, "/login", "", 1)
http.FileServer(loginUIBox).ServeHTTP(w, r)
}
})
r.Post("/init", func(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
Expand Down
127 changes: 127 additions & 0 deletions pkg/api/session.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package api

import (
"fmt"
"html/template"
"net/http"

"github.com/stashapp/stash/pkg/manager/config"

"github.com/gorilla/sessions"
)

const cookieName = "session"
const usernameFormKey = "username"
const passwordFormKey = "password"
const userIDKey = "userID"

const returnURLParam = "returnURL"

var sessionStore = sessions.NewCookieStore(config.GetSessionStoreKey())

type loginTemplateData struct {
URL string
Error string
}

func initSessionStore() {
sessionStore.MaxAge(config.GetMaxSessionAge())
}

func redirectToLogin(w http.ResponseWriter, returnURL string, loginError string) {
data, _ := loginUIBox.Find("login.html")
templ, err := template.New("Login").Parse(string(data))
if err != nil {
http.Error(w, fmt.Sprintf("error: %s", err), http.StatusInternalServerError)
return
}

err = templ.Execute(w, loginTemplateData{URL: returnURL, Error: loginError})
if err != nil {
http.Error(w, fmt.Sprintf("error: %s", err), http.StatusInternalServerError)
}
}

func getLoginHandler(w http.ResponseWriter, r *http.Request) {
if !config.HasCredentials() {
http.Redirect(w, r, "/", http.StatusFound)
return
}

redirectToLogin(w, r.URL.Query().Get(returnURLParam), "")
}

func handleLogin(w http.ResponseWriter, r *http.Request) {
url := r.FormValue(returnURLParam)
if url == "" {
url = "/"
}

// ignore error - we want a new session regardless
newSession, _ := sessionStore.Get(r, cookieName)

username := r.FormValue("username")
password := r.FormValue("password")

// authenticate the user
if !config.ValidateCredentials(username, password) {
// redirect back to the login page with an error
redirectToLogin(w, url, "Username or password is invalid")
return
}

newSession.Values[userIDKey] = username

err := newSession.Save(r, w)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

http.Redirect(w, r, url, http.StatusFound)
}

func handleLogout(w http.ResponseWriter, r *http.Request) {
session, err := sessionStore.Get(r, cookieName)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

delete(session.Values, userIDKey)
session.Options.MaxAge = -1

err = session.Save(r, w)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

// redirect to the login page if credentials are required
getLoginHandler(w, r)
}

func getSessionUserID(w http.ResponseWriter, r *http.Request) (string, error) {
session, err := sessionStore.Get(r, cookieName)
// ignore errors and treat as an empty user id, so that we handle expired
// cookie
if err != nil {
return "", nil
}

if !session.IsNew {
val := session.Values[userIDKey]

// refresh the cookie
err = session.Save(r, w)
if err != nil {
return "", err
}

ret, _ := val.(string)

return ret, nil
}

return "", nil
}
42 changes: 42 additions & 0 deletions pkg/manager/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ const Metadata = "metadata"
const Downloads = "downloads"
const Username = "username"
const Password = "password"
const MaxSessionAge = "max_session_age"

const DefaultMaxSessionAge = 60 * 60 * 1 // 1 hours

const Database = "database"

Expand All @@ -31,6 +34,12 @@ const Host = "host"
const Port = "port"
const ExternalHost = "external_host"

// key used to sign JWT tokens
const JWTSignKey = "jwt_secret_key"

// key used for session store
const SessionStoreKey = "session_store_key"

// scraping options
const ScrapersPath = "scrapers_path"
const ScraperUserAgent = "scraper_user_agent"
Expand Down Expand Up @@ -89,6 +98,14 @@ func GetDatabasePath() string {
return viper.GetString(Database)
}

func GetJWTSignKey() []byte {
return []byte(viper.GetString(JWTSignKey))
}

func GetSessionStoreKey() []byte {
return []byte(viper.GetString(SessionStoreKey))
}

func GetDefaultScrapersPath() string {
// default to the same directory as the config file
configFileUsed := viper.ConfigFileUsed()
Expand Down Expand Up @@ -202,6 +219,13 @@ func ValidateCredentials(username string, password string) bool {
return username == authUser && err == nil
}

// GetMaxSessionAge gets the maximum age for session cookies, in seconds.
// Session cookie expiry times are refreshed every request.
func GetMaxSessionAge() int {
viper.SetDefault(MaxSessionAge, DefaultMaxSessionAge)
return viper.GetInt(MaxSessionAge)
}

// Interface options
func GetSoundOnPreview() bool {
viper.SetDefault(SoundOnPreview, true)
Expand Down Expand Up @@ -315,3 +339,21 @@ func IsValid() bool {
// TODO: check valid paths
return setPaths
}

// SetInitialConfig fills in missing required config fields
func SetInitialConfig() error {
// generate some api keys
const apiKeyLength = 32

if string(GetJWTSignKey()) == "" {
signKey := utils.GenerateRandomKey(apiKeyLength)
Set(JWTSignKey, signKey)
}

if string(GetSessionStoreKey()) == "" {
sessionStoreKey := utils.GenerateRandomKey(apiKeyLength)
Set(SessionStoreKey, sessionStoreKey)
}

return Write()
}
Loading

0 comments on commit 15e7756

Please sign in to comment.