@ -9,21 +9,28 @@ import (
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt"
"github.com/golang-jwt/jwt"
"github.com/hrfee/mediabrowser"
"github.com/lithammer/shortuuid/v3"
"github.com/lithammer/shortuuid/v3"
)
)
const (
TOKEN_VALIDITY_SEC = 20 * 60
REFRESH_TOKEN_VALIDITY_SEC = 3600 * 24
)
func ( app * appContext ) webAuth ( ) gin . HandlerFunc {
func ( app * appContext ) webAuth ( ) gin . HandlerFunc {
return app . authenticate
return app . authenticate
}
}
// CreateToken returns a web token as well as a refresh token, which can be used to obtain new tokens.
// 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
var token , refresh string
claims := jwt . MapClaims {
claims := jwt . MapClaims {
"valid" : true ,
"valid" : true ,
"id" : userId ,
"id" : userId ,
"exp" : time . Now ( ) . Add ( time . Minute * 20 ) . Unix ( ) ,
"exp" : time . Now ( ) . Add ( time . Second * TOKEN_VALIDITY_SEC ) . Unix ( ) ,
"jfid" : jfId ,
"jfid" : jfId ,
"admin" : admin ,
"type" : "bearer" ,
"type" : "bearer" ,
}
}
tk := jwt . NewWithClaims ( jwt . SigningMethodHS256 , claims )
tk := jwt . NewWithClaims ( jwt . SigningMethodHS256 , claims )
@ -31,7 +38,7 @@ func CreateToken(userId, jfId string) (string, string, error) {
if err != nil {
if err != nil {
return "" , "" , err
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"
claims [ "type" ] = "refresh"
tk = jwt . NewWithClaims ( jwt . SigningMethodHS256 , claims )
tk = jwt . NewWithClaims ( jwt . SigningMethodHS256 , claims )
refresh , err = tk . SignedString ( [ ] byte ( os . Getenv ( "JFA_SECRET" ) ) )
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
return token , refresh , nil
}
}
// Check header for token
// Caller should return if this returns false.
func ( app * appContext ) authenticate ( gc * gin . Context ) {
func ( app * appContext ) decodeValidateAuthHeader ( gc * gin . Context ) ( claims jwt . MapClaims , ok bool ) {
ok = false
header := strings . SplitN ( gc . Request . Header . Get ( "Authorization" ) , " " , 2 )
header := strings . SplitN ( gc . Request . Header . Get ( "Authorization" ) , " " , 2 )
if header [ 0 ] != "Bearer" {
if header [ 0 ] != "Bearer" {
app . debug . Println ( "Invalid authorization header" )
app . debug . Println ( "Invalid authorization header" )
@ -55,7 +63,13 @@ func (app *appContext) authenticate(gc *gin.Context) {
respond ( 401 , "Unauthorized" , gc )
respond ( 401 , "Unauthorized" , gc )
return
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 ) )
expiryUnix := int64 ( claims [ "exp" ] . ( float64 ) )
if err != nil {
if err != nil {
app . debug . Printf ( "Auth denied: %s" , err )
app . debug . Printf ( "Auth denied: %s" , err )
@ -68,10 +82,27 @@ func (app *appContext) authenticate(gc *gin.Context) {
respond ( 401 , "Unauthorized" , gc )
respond ( 401 , "Unauthorized" , gc )
return
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 )
userID := claims [ "id" ] . ( string )
jfID := claims [ "jfid" ] . ( string )
jfID := claims [ "jfid" ] . ( string )
match := false
match := false
for _ , user := range app . users {
for _ , user := range app . adminU sers {
if user . UserID == userID {
if user . UserID == userID {
match = true
match = true
break
break
@ -84,6 +115,7 @@ func (app *appContext) authenticate(gc *gin.Context) {
}
}
gc . Set ( "jfId" , jfID )
gc . Set ( "jfId" , jfID )
gc . Set ( "userId" , userID )
gc . Set ( "userId" , userID )
gc . Set ( "userMode" , false )
app . debug . Println ( "Auth succeeded" )
app . debug . Println ( "Auth succeeded" )
gc . Next ( )
gc . Next ( )
}
}
@ -99,6 +131,39 @@ type getTokenDTO struct {
Token string ` json:"token" example:"kjsdklsfdkljfsjsdfklsdfkldsfjdfskjsdfjklsdf" ` // API token for use with everything else.
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.
// @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`".
// @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
// @Produce json
@ -109,18 +174,14 @@ type getTokenDTO struct {
// @Security getTokenAuth
// @Security getTokenAuth
func ( app * appContext ) getTokenLogin ( gc * gin . Context ) {
func ( app * appContext ) getTokenLogin ( gc * gin . Context ) {
app . info . Println ( "Token requested (login attempt)" )
app . info . Println ( "Token requested (login attempt)" )
header := strings . SplitN ( gc . Request . Header . Get ( "Authorization" ) , " " , 2 )
username , password , ok := app . decodeValidateLoginHeader ( gc )
auth , _ := base64 . StdEncoding . DecodeString ( header [ 1 ] )
if ! ok {
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 )
return
return
}
}
var userID , jfID string
match := false
match := false
for _ , user := range app . u sers {
for _ , user := range app . adminUsers {
if user . Username == creds[ 0 ] && user . Password == creds [ 1 ] {
if user . Username == username && user . Password == password {
match = true
match = true
app . debug . Println ( "Found existing user" )
app . debug . Println ( "Found existing user" )
userID = user . UserID
userID = user . UserID
@ -133,15 +194,8 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
return
return
}
}
if ! match {
if ! match {
user , status , err := app . authJf . Authenticate ( creds [ 0 ] , creds [ 1 ] )
user , ok := app . validateJellyfinCredentials ( username , password , gc )
if status != 200 || err != nil {
if ! ok {
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
return
}
}
jfID = user . ID
jfID = user . ID
@ -153,7 +207,7 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
}
}
accountsAdmin = accountsAdmin || ( adminOnly && user . Policy . IsAdministrator )
accountsAdmin = accountsAdmin || ( adminOnly && user . Policy . IsAdministrator )
if ! accountsAdmin {
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 )
respond ( 401 , "Unauthorized" , gc )
return
return
}
}
@ -163,10 +217,10 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
newUser := User {
newUser := User {
UserID : userID ,
UserID : userID ,
}
}
app . debug . Printf ( "Token generated for user \"%s\"" , creds[ 0 ] )
app . debug . Printf ( "Token generated for user \"%s\"" , username )
app . users = append ( app . u sers, newUser )
app . adminUsers = append ( app . adminU sers, newUser )
}
}
token , refresh , err := CreateToken ( userID , jfID )
token , refresh , err := CreateToken ( userID , jfID , true )
if err != nil {
if err != nil {
app . err . Printf ( "getToken failed: Couldn't generate token (%s)" , err )
app . err . Printf ( "getToken failed: Couldn't generate token (%s)" , err )
respond ( 500 , "Couldn't generate token" , gc )
respond ( 500 , "Couldn't generate token" , gc )
@ -176,14 +230,8 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
gc . JSON ( 200 , getTokenDTO { token } )
gc . JSON ( 200 , getTokenDTO { token } )
}
}
// @Summary Grabs an API token using a refresh token from cookies.
func ( app * appContext ) decodeValidateRefreshCookie ( gc * gin . Context ) ( claims jwt . MapClaims , ok bool ) {
// @Produce json
ok = false
// @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)" )
cookie , err := gc . Cookie ( "refresh" )
cookie , err := gc . Cookie ( "refresh" )
if err != nil || cookie == "" {
if err != nil || cookie == "" {
app . debug . Printf ( "getTokenRefresh denied: Couldn't get token: %s" , err )
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 )
respond ( 400 , "Invalid token" , gc )
return
return
}
}
claims , ok : = token . Claims . ( jwt . MapClaims )
claims , ok = token . Claims . ( jwt . MapClaims )
expiryUnix := int64 ( claims [ "exp" ] . ( float64 ) )
expiryUnix := int64 ( claims [ "exp" ] . ( float64 ) )
if err != nil {
if err != nil {
app . debug . Printf ( "getTokenRefresh: Invalid token expiry: %s" , err )
app . debug . Printf ( "getTokenRefresh: Invalid token expiry: %s" , err )
respond ( 401 , "Invalid token" , gc )
respond ( 401 , "Invalid token" , gc )
return
return
}
}
ok = false
expiry := time . Unix ( expiryUnix , 0 )
expiry := time . Unix ( expiryUnix , 0 )
if ! ( ok && token . Valid && claims [ "type" ] . ( string ) == "refresh" && expiry . After ( time . Now ( ) ) ) {
if ! ( ok && token . Valid && claims [ "type" ] . ( string ) == "refresh" && expiry . After ( time . Now ( ) ) ) {
app . debug . Printf ( "getTokenRefresh: Invalid token: %s" , err )
app . debug . Printf ( "getTokenRefresh: Invalid token: %s" , err )
respond ( 401 , "Invalid token" , gc )
respond ( 401 , "Invalid token" , gc )
return
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 )
userID := claims [ "id" ] . ( string )
jfID := claims [ "jfid" ] . ( string )
jfID := claims [ "jfid" ] . ( string )
jwt , refresh , err := CreateToken ( userID , jfID )
jwt , refresh , err := CreateToken ( userID , jfID , true )
if err != nil {
if err != nil {
app . err . Printf ( "getTokenRefresh failed: Couldn't generate token (%s)" , err )
app . err . Printf ( "getTokenRefresh failed: Couldn't generate token (%s)" , err )
respond ( 500 , "Couldn't generate token" , gc )
respond ( 500 , "Couldn't generate token" , gc )
return
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 } )
gc . JSON ( 200 , getTokenDTO { jwt } )
}
}