mirror of
https://github.com/hrfee/jfa-go.git
synced 2024-12-22 17:10:10 +00:00
auth: slight refactor, setup user auth
user-auth.go contains slightly adjusted versions of auth.go functions, for authorizing jellyfin users (admin or not). Refactored auth.go so that most code is shared. User auth isn't hooked up yet, nor has it been tested.
This commit is contained in:
parent
bf981935cb
commit
918f8816c5
145
auth.go
145
auth.go
@ -9,21 +9,28 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/hrfee/mediabrowser"
|
||||
"github.com/lithammer/shortuuid/v3"
|
||||
)
|
||||
|
||||
const (
|
||||
TOKEN_VALIDITY_SEC = 20 * 60
|
||||
REFRESH_TOKEN_VALIDITY_SEC = 3600 * 24
|
||||
)
|
||||
|
||||
func (app *appContext) webAuth() gin.HandlerFunc {
|
||||
return app.authenticate
|
||||
}
|
||||
|
||||
// CreateToken returns a web token as well as a refresh token, which can be used to obtain new tokens.
|
||||
func CreateToken(userId, jfId string) (string, string, error) {
|
||||
func CreateToken(userId, jfId string, admin bool) (string, string, error) {
|
||||
var token, refresh string
|
||||
claims := jwt.MapClaims{
|
||||
"valid": true,
|
||||
"id": userId,
|
||||
"exp": time.Now().Add(time.Minute * 20).Unix(),
|
||||
"exp": time.Now().Add(time.Second * TOKEN_VALIDITY_SEC).Unix(),
|
||||
"jfid": jfId,
|
||||
"admin": admin,
|
||||
"type": "bearer",
|
||||
}
|
||||
tk := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
@ -31,7 +38,7 @@ func CreateToken(userId, jfId string) (string, string, error) {
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
claims["exp"] = time.Now().Add(time.Hour * 24).Unix()
|
||||
claims["exp"] = time.Now().Add(time.Second * REFRESH_TOKEN_VALIDITY_SEC).Unix()
|
||||
claims["type"] = "refresh"
|
||||
tk = jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
refresh, err = tk.SignedString([]byte(os.Getenv("JFA_SECRET")))
|
||||
@ -41,8 +48,9 @@ func CreateToken(userId, jfId string) (string, string, error) {
|
||||
return token, refresh, nil
|
||||
}
|
||||
|
||||
// Check header for token
|
||||
func (app *appContext) authenticate(gc *gin.Context) {
|
||||
// Caller should return if this returns false.
|
||||
func (app *appContext) decodeValidateAuthHeader(gc *gin.Context) (claims jwt.MapClaims, ok bool) {
|
||||
ok = false
|
||||
header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2)
|
||||
if header[0] != "Bearer" {
|
||||
app.debug.Println("Invalid authorization header")
|
||||
@ -55,7 +63,13 @@ func (app *appContext) authenticate(gc *gin.Context) {
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
claims, ok = token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
app.debug.Println("Invalid JWT")
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
ok = false
|
||||
expiryUnix := int64(claims["exp"].(float64))
|
||||
if err != nil {
|
||||
app.debug.Printf("Auth denied: %s", err)
|
||||
@ -68,10 +82,27 @@ func (app *appContext) authenticate(gc *gin.Context) {
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
|
||||
// Check header for token
|
||||
func (app *appContext) authenticate(gc *gin.Context) {
|
||||
claims, ok := app.decodeValidateAuthHeader(gc)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
isAdminToken := claims["admin"].(bool)
|
||||
if !isAdminToken {
|
||||
app.debug.Printf("Auth denied: Token was not for admin access")
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
|
||||
userID := claims["id"].(string)
|
||||
jfID := claims["jfid"].(string)
|
||||
match := false
|
||||
for _, user := range app.users {
|
||||
for _, user := range app.adminUsers {
|
||||
if user.UserID == userID {
|
||||
match = true
|
||||
break
|
||||
@ -84,6 +115,7 @@ func (app *appContext) authenticate(gc *gin.Context) {
|
||||
}
|
||||
gc.Set("jfId", jfID)
|
||||
gc.Set("userId", userID)
|
||||
gc.Set("userMode", false)
|
||||
app.debug.Println("Auth succeeded")
|
||||
gc.Next()
|
||||
}
|
||||
@ -99,6 +131,39 @@ type getTokenDTO struct {
|
||||
Token string `json:"token" example:"kjsdklsfdkljfsjsdfklsdfkldsfjdfskjsdfjklsdf"` // API token for use with everything else.
|
||||
}
|
||||
|
||||
func (app *appContext) decodeValidateLoginHeader(gc *gin.Context) (username, password string, ok bool) {
|
||||
header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2)
|
||||
auth, _ := base64.StdEncoding.DecodeString(header[1])
|
||||
creds := strings.SplitN(string(auth), ":", 2)
|
||||
username = creds[0]
|
||||
password = creds[1]
|
||||
ok = false
|
||||
if username == "" || password == "" {
|
||||
app.debug.Println("Auth denied: blank username/password")
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
|
||||
func (app *appContext) validateJellyfinCredentials(username, password string, gc *gin.Context) (user mediabrowser.User, ok bool) {
|
||||
ok = false
|
||||
user, status, err := app.authJf.Authenticate(username, password)
|
||||
if status != 200 || err != nil {
|
||||
if status == 401 || status == 400 {
|
||||
app.info.Println("Auth denied: Invalid username/password (Jellyfin)")
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
app.err.Printf("Auth failed: Couldn't authenticate with Jellyfin (%d/%s)", status, err)
|
||||
respond(500, "Jellyfin error", gc)
|
||||
return
|
||||
}
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
|
||||
// @Summary Grabs an API token using username & password.
|
||||
// @description If viewing docs locally, click the lock icon next to this, login with your normal jfa-go credentials. Click 'try it out', then 'execute' and an API Key will be returned, copy it (not including quotes). On any of the other routes, click the lock icon and set the API key as "Bearer `your api key`".
|
||||
// @Produce json
|
||||
@ -109,18 +174,14 @@ type getTokenDTO struct {
|
||||
// @Security getTokenAuth
|
||||
func (app *appContext) getTokenLogin(gc *gin.Context) {
|
||||
app.info.Println("Token requested (login attempt)")
|
||||
header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2)
|
||||
auth, _ := base64.StdEncoding.DecodeString(header[1])
|
||||
creds := strings.SplitN(string(auth), ":", 2)
|
||||
var userID, jfID string
|
||||
if creds[0] == "" || creds[1] == "" {
|
||||
app.debug.Println("Auth denied: blank username/password")
|
||||
respond(401, "Unauthorized", gc)
|
||||
username, password, ok := app.decodeValidateLoginHeader(gc)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var userID, jfID string
|
||||
match := false
|
||||
for _, user := range app.users {
|
||||
if user.Username == creds[0] && user.Password == creds[1] {
|
||||
for _, user := range app.adminUsers {
|
||||
if user.Username == username && user.Password == password {
|
||||
match = true
|
||||
app.debug.Println("Found existing user")
|
||||
userID = user.UserID
|
||||
@ -133,15 +194,8 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
|
||||
return
|
||||
}
|
||||
if !match {
|
||||
user, status, err := app.authJf.Authenticate(creds[0], creds[1])
|
||||
if status != 200 || err != nil {
|
||||
if status == 401 || status == 400 {
|
||||
app.info.Println("Auth denied: Invalid username/password (Jellyfin)")
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
app.err.Printf("Auth failed: Couldn't authenticate with Jellyfin (%d/%s)", status, err)
|
||||
respond(500, "Jellyfin error", gc)
|
||||
user, ok := app.validateJellyfinCredentials(username, password, gc)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
jfID = user.ID
|
||||
@ -153,7 +207,7 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
|
||||
}
|
||||
accountsAdmin = accountsAdmin || (adminOnly && user.Policy.IsAdministrator)
|
||||
if !accountsAdmin {
|
||||
app.debug.Printf("Auth denied: Users \"%s\" isn't admin", creds[0])
|
||||
app.debug.Printf("Auth denied: Users \"%s\" isn't admin", username)
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
@ -163,10 +217,10 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
|
||||
newUser := User{
|
||||
UserID: userID,
|
||||
}
|
||||
app.debug.Printf("Token generated for user \"%s\"", creds[0])
|
||||
app.users = append(app.users, newUser)
|
||||
app.debug.Printf("Token generated for user \"%s\"", username)
|
||||
app.adminUsers = append(app.adminUsers, newUser)
|
||||
}
|
||||
token, refresh, err := CreateToken(userID, jfID)
|
||||
token, refresh, err := CreateToken(userID, jfID, true)
|
||||
if err != nil {
|
||||
app.err.Printf("getToken failed: Couldn't generate token (%s)", err)
|
||||
respond(500, "Couldn't generate token", gc)
|
||||
@ -176,14 +230,8 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
|
||||
gc.JSON(200, getTokenDTO{token})
|
||||
}
|
||||
|
||||
// @Summary Grabs an API token using a refresh token from cookies.
|
||||
// @Produce json
|
||||
// @Success 200 {object} getTokenDTO
|
||||
// @Failure 401 {object} stringResponse
|
||||
// @Router /token/refresh [get]
|
||||
// @tags Auth
|
||||
func (app *appContext) getTokenRefresh(gc *gin.Context) {
|
||||
app.debug.Println("Token requested (refresh token)")
|
||||
func (app *appContext) decodeValidateRefreshCookie(gc *gin.Context) (claims jwt.MapClaims, ok bool) {
|
||||
ok = false
|
||||
cookie, err := gc.Cookie("refresh")
|
||||
if err != nil || cookie == "" {
|
||||
app.debug.Printf("getTokenRefresh denied: Couldn't get token: %s", err)
|
||||
@ -203,27 +251,44 @@ func (app *appContext) getTokenRefresh(gc *gin.Context) {
|
||||
respond(400, "Invalid token", gc)
|
||||
return
|
||||
}
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
claims, ok = token.Claims.(jwt.MapClaims)
|
||||
expiryUnix := int64(claims["exp"].(float64))
|
||||
if err != nil {
|
||||
app.debug.Printf("getTokenRefresh: Invalid token expiry: %s", err)
|
||||
respond(401, "Invalid token", gc)
|
||||
return
|
||||
}
|
||||
ok = false
|
||||
expiry := time.Unix(expiryUnix, 0)
|
||||
if !(ok && token.Valid && claims["type"].(string) == "refresh" && expiry.After(time.Now())) {
|
||||
app.debug.Printf("getTokenRefresh: Invalid token: %s", err)
|
||||
respond(401, "Invalid token", gc)
|
||||
return
|
||||
}
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
|
||||
// @Summary Grabs an API token using a refresh token from cookies.
|
||||
// @Produce json
|
||||
// @Success 200 {object} getTokenDTO
|
||||
// @Failure 401 {object} stringResponse
|
||||
// @Router /token/refresh [get]
|
||||
// @tags Auth
|
||||
func (app *appContext) getTokenRefresh(gc *gin.Context) {
|
||||
app.debug.Println("Token requested (refresh token)")
|
||||
claims, ok := app.decodeValidateRefreshCookie(gc)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID := claims["id"].(string)
|
||||
jfID := claims["jfid"].(string)
|
||||
jwt, refresh, err := CreateToken(userID, jfID)
|
||||
jwt, refresh, err := CreateToken(userID, jfID, true)
|
||||
if err != nil {
|
||||
app.err.Printf("getTokenRefresh failed: Couldn't generate token (%s)", err)
|
||||
respond(500, "Couldn't generate token", gc)
|
||||
return
|
||||
}
|
||||
gc.SetCookie("refresh", refresh, (3600 * 24), "/", gc.Request.URL.Hostname(), true, true)
|
||||
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", gc.Request.URL.Hostname(), true, true)
|
||||
gc.JSON(200, getTokenDTO{jwt})
|
||||
}
|
||||
|
7
main.go
7
main.go
@ -87,7 +87,7 @@ type appContext struct {
|
||||
webFS httpFS
|
||||
cssClass string // Default theme, "light"|"dark".
|
||||
jellyfinLogin bool
|
||||
users []User
|
||||
adminUsers []User
|
||||
invalidTokens []string
|
||||
// Keeping jf name because I can't think of a better one
|
||||
jf *mediabrowser.MediaBrowser
|
||||
@ -450,7 +450,7 @@ func start(asDaemon, firstCall bool) {
|
||||
user.UserID = shortuuid.New()
|
||||
user.Username = app.config.Section("ui").Key("username").String()
|
||||
user.Password = app.config.Section("ui").Key("password").String()
|
||||
app.users = append(app.users, user)
|
||||
app.adminUsers = append(app.adminUsers, user)
|
||||
} else {
|
||||
app.debug.Println("Using Jellyfin for authentication")
|
||||
app.authJf, _ = mediabrowser.NewServer(serverType, server, "jfa-go", app.version, "auth", "auth", timeoutHandler, cacheTimeout)
|
||||
@ -645,6 +645,9 @@ func flagPassed(name string) (found bool) {
|
||||
// @securityDefinitions.basic getTokenAuth
|
||||
// @name getTokenAuth
|
||||
|
||||
// @securityDefinitions.basic getUserTokenAuth
|
||||
// @name getUserTokenAuth
|
||||
|
||||
// @tag.name Auth
|
||||
// @tag.description -Get a token here if running swagger UI locally.-
|
||||
|
||||
|
98
user-auth.go
Normal file
98
user-auth.go
Normal file
@ -0,0 +1,98 @@
|
||||
package main
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func (app *appContext) userAuth() gin.HandlerFunc {
|
||||
return app.userAuthenticate
|
||||
}
|
||||
|
||||
func (app *appContext) userAuthenticate(gc *gin.Context) {
|
||||
jellyfinLogin := app.config.Section("ui").Key("jellyfin_login").MustBool(true)
|
||||
if !jellyfinLogin {
|
||||
app.err.Println("Enable Jellyfin Login to use the User Page feature.")
|
||||
respond(500, "Contact Admin", gc)
|
||||
return
|
||||
}
|
||||
claims, ok := app.decodeValidateAuthHeader(gc)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// user id can be nil for all we care, we just want the Jellyfin ID
|
||||
jfID := claims["jfid"].(string)
|
||||
|
||||
gc.Set("jfId", jfID)
|
||||
gc.Set("userMode", true)
|
||||
app.debug.Println("Auth succeeded")
|
||||
gc.Next()
|
||||
}
|
||||
|
||||
// @Summary Grabs an user-access token using username & password.
|
||||
// @description Has limited access to API routes, used to display the user's personal page.
|
||||
// @Produce json
|
||||
// @Success 200 {object} getTokenDTO
|
||||
// @Failure 401 {object} stringResponse
|
||||
// @Router /my/token/login [get]
|
||||
// @tags Auth
|
||||
// @Security getUserTokenAuth
|
||||
func (app *appContext) getUserTokenLogin(gc *gin.Context) {
|
||||
if !app.config.Section("ui").Key("jellyfin_login").MustBool(true) {
|
||||
app.err.Println("Enable Jellyfin Login to use the User Page feature.")
|
||||
respond(500, "Contact Admin", gc)
|
||||
return
|
||||
}
|
||||
app.info.Println("UserToken requested (login attempt)")
|
||||
username, password, ok := app.decodeValidateLoginHeader(gc)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := app.validateJellyfinCredentials(username, password, gc)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
token, refresh, err := CreateToken(user.ID, user.ID, false)
|
||||
if err != nil {
|
||||
app.err.Printf("getUserToken failed: Couldn't generate user token (%s)", err)
|
||||
respond(500, "Couldn't generate user token", gc)
|
||||
return
|
||||
}
|
||||
|
||||
app.debug.Printf("Token generated for non-admin user \"%s\"", username)
|
||||
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/my", gc.Request.URL.Hostname(), true, true)
|
||||
gc.JSON(200, getTokenDTO{token})
|
||||
}
|
||||
|
||||
// @Summary Grabs an user-access token using a refresh token from cookies.
|
||||
// @Produce json
|
||||
// @Success 200 {object} getTokenDTO
|
||||
// @Failure 401 {object} stringResponse
|
||||
// @Router /my/token/refresh [get]
|
||||
// @tags Auth
|
||||
func (app *appContext) getUserTokenRefresh(gc *gin.Context) {
|
||||
jellyfinLogin := app.config.Section("ui").Key("jellyfin_login").MustBool(true)
|
||||
if !jellyfinLogin {
|
||||
app.err.Println("Enable Jellyfin Login to use the User Page feature.")
|
||||
respond(500, "Contact Admin", gc)
|
||||
return
|
||||
}
|
||||
|
||||
app.info.Println("UserToken request (refresh token)")
|
||||
claims, ok := app.decodeValidateRefreshCookie(gc)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
jfID := claims["jfid"].(string)
|
||||
|
||||
jwt, refresh, err := CreateToken(jfID, jfID, false)
|
||||
if err != nil {
|
||||
app.err.Printf("getUserToken failed: Couldn't generate user token (%s)", err)
|
||||
respond(500, "Couldn't generate user token", gc)
|
||||
return
|
||||
}
|
||||
|
||||
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/my", gc.Request.URL.Hostname(), true, true)
|
||||
gc.JSON(200, getTokenDTO{jwt})
|
||||
}
|
Loading…
Reference in New Issue
Block a user