Auth: Improve "auth add" and "client add" CLI commands #808 #3943

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2024-01-13 16:27:05 +01:00
parent 94370bbc39
commit e21e462f00
11 changed files with 90 additions and 56 deletions

View File

@@ -17,16 +17,15 @@ import (
var AuthAddFlags = []cli.Flag{ var AuthAddFlags = []cli.Flag{
cli.StringFlag{ cli.StringFlag{
Name: "name, n", Name: "name, n",
Usage: "arbitrary `IDENTIFIER` for the new access token", Usage: "arbitrary name to help identify the access `TOKEN`",
}, },
cli.StringFlag{ cli.StringFlag{
Name: "user, u", Name: "user, u",
Usage: "provide a `USERNAME` if a personal access token for a specific user account should be created", Usage: "`USERNAME` of the account the access token belongs to (leave empty for none)",
}, },
cli.StringFlag{ cli.StringFlag{
Name: "scope, s", Name: "scope, s",
Usage: "authorization `SCOPE` for the access token e.g. \"metrics\" or \"photos albums\" (\"*\" to allow all scopes)", Usage: "authorization `SCOPE` for the access token e.g. \"metrics\" or \"photos albums\" (\"*\" to allow all scopes)",
Value: "*",
}, },
cli.Int64Flag{ cli.Int64Flag{
Name: "expires, e", Name: "expires, e",
@@ -38,7 +37,7 @@ var AuthAddFlags = []cli.Flag{
// AuthAddCommand configures the command name, flags, and action. // AuthAddCommand configures the command name, flags, and action.
var AuthAddCommand = cli.Command{ var AuthAddCommand = cli.Command{
Name: "add", Name: "add",
Usage: "Creates a new client access token and shows it", Usage: "Creates a new client access token",
Flags: AuthAddFlags, Flags: AuthAddFlags,
Action: authAddAction, Action: authAddAction,
} }
@@ -46,11 +45,23 @@ var AuthAddCommand = cli.Command{
// authAddAction shows detailed session information. // authAddAction shows detailed session information.
func authAddAction(ctx *cli.Context) error { func authAddAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error { return CallWithDependencies(ctx, func(conf *config.Config) error {
name := ctx.String("name") // Get username from command flag.
userName := ctx.String("user")
if name == "" { // Find user account.
user := entity.FindUserByName(userName)
if user == nil && userName != "" {
return fmt.Errorf("user %s not found", clean.LogQuote(userName))
}
// Get token name from command flag or ask for it.
tokenName := ctx.String("name")
if tokenName == "" {
prompt := promptui.Prompt{ prompt := promptui.Prompt{
Label: "Token Name", Label: "Token Name",
Default: rnd.Name(),
} }
res, err := prompt.Run() res, err := prompt.Run()
@@ -59,24 +70,29 @@ func authAddAction(ctx *cli.Context) error {
return err return err
} }
name = clean.Name(res) tokenName = clean.Name(res)
} }
// Set a default token name if no specific name has been provided. // Get auth scope from command flag or ask for it.
if name == "" { authScope := ctx.String("scope")
name = rnd.Name()
}
// Username provided? if authScope == "" {
userName := ctx.String("user") prompt := promptui.Prompt{
user := entity.FindUserByName(userName) Label: "Authorization Scope",
Default: "*",
}
if user == nil && userName != "" { res, err := prompt.Run()
return fmt.Errorf("user %s not found", clean.LogQuote(userName))
if err != nil {
return err
}
authScope = clean.Scope(res)
} }
// Create client session. // Create client session.
sess, err := entity.CreateClientAccessToken(name, ctx.Int64("expires"), ctx.String("scope"), user) sess, err := entity.CreateClientAccessToken(tokenName, ctx.Int64("expires"), authScope, user)
if err != nil { if err != nil {
return fmt.Errorf("failed to create access token: %s", err) return fmt.Errorf("failed to create access token: %s", err)

View File

@@ -1,16 +1,17 @@
package commands package commands
import ( import (
"github.com/photoprism/photoprism/pkg/authn"
"github.com/urfave/cli" "github.com/urfave/cli"
"github.com/photoprism/photoprism/pkg/authn"
) )
// Usage hints for the client management subcommands. // Usage hints for the client management subcommands.
const ( const (
ClientNameUsage = "arbitrary name to help identify the `CLIENT` application" ClientNameUsage = "arbitrary name to help identify the `CLIENT` application"
ClientUserName = "provide a `USERNAME` if the client belongs to a specific user account" ClientUserName = "`USERNAME` of the account the client application belongs to (leave empty for none)"
ClientAuthMethod = "supported authentication `METHOD` for the client application"
ClientAuthScope = "authorization `SCOPE` of the client e.g. \"metrics\" or \"photos albums\" (\"*\" to allow all scopes)" ClientAuthScope = "authorization `SCOPE` of the client e.g. \"metrics\" or \"photos albums\" (\"*\" to allow all scopes)"
ClientAuthMethod = "supported authentication `METHOD` for the client application"
ClientAuthExpires = "access token lifetime in `SECONDS`, after which a new token must be created by the client (-1 to disable)" ClientAuthExpires = "access token lifetime in `SECONDS`, after which a new token must be created by the client (-1 to disable)"
ClientAuthTokens = "maximum `NUMBER` of access tokens the client can create (-1 to disable)" ClientAuthTokens = "maximum `NUMBER` of access tokens the client can create (-1 to disable)"
ClientRegenerateSecret = "generate a new client secret and display it" ClientRegenerateSecret = "generate a new client secret and display it"
@@ -43,15 +44,16 @@ var ClientAddFlags = []cli.Flag{
Name: "user, u", Name: "user, u",
Usage: ClientUserName, Usage: ClientUserName,
}, },
cli.StringFlag{
Name: "method, m",
Usage: ClientAuthMethod,
Value: authn.MethodOAuth2.String(),
},
cli.StringFlag{ cli.StringFlag{
Name: "scope, s", Name: "scope, s",
Usage: ClientAuthScope, Usage: ClientAuthScope,
}, },
cli.StringFlag{
Name: "method, m",
Usage: ClientAuthMethod,
Value: authn.MethodOAuth2.String(),
Hidden: true,
},
cli.Int64Flag{ cli.Int64Flag{
Name: "expires, e", Name: "expires, e",
Usage: ClientAuthExpires, Usage: ClientAuthExpires,
@@ -68,15 +70,16 @@ var ClientModFlags = []cli.Flag{
Name: "name, n", Name: "name, n",
Usage: ClientNameUsage, Usage: ClientNameUsage,
}, },
cli.StringFlag{
Name: "method, m",
Usage: ClientAuthMethod,
Value: authn.MethodOAuth2.String(),
},
cli.StringFlag{ cli.StringFlag{
Name: "scope, s", Name: "scope, s",
Usage: ClientAuthScope, Usage: ClientAuthScope,
}, },
cli.StringFlag{
Name: "method, m",
Usage: ClientAuthMethod,
Value: authn.MethodOAuth2.String(),
Hidden: true,
},
cli.Int64Flag{ cli.Int64Flag{
Name: "expires, e", Name: "expires, e",
Usage: ClientAuthExpires, Usage: ClientAuthExpires,

View File

@@ -13,6 +13,7 @@ import (
"github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/list" "github.com/photoprism/photoprism/pkg/list"
"github.com/photoprism/photoprism/pkg/report" "github.com/photoprism/photoprism/pkg/report"
"github.com/photoprism/photoprism/pkg/rnd"
) )
// ClientsAddCommand configures the command name, flags, and action. // ClientsAddCommand configures the command name, flags, and action.
@@ -39,7 +40,8 @@ func clientsAddAction(ctx *cli.Context) error {
if interactive && frm.ClientName == "" { if interactive && frm.ClientName == "" {
prompt := promptui.Prompt{ prompt := promptui.Prompt{
Label: "Client Name", Label: "Client Name",
Default: rnd.Name(),
} }
res, err := prompt.Run() res, err := prompt.Run()

View File

@@ -43,11 +43,11 @@ var ClientFixtures = ClientMap{
UserName: UserFixtures.Pointer("bob").UserName, UserName: UserFixtures.Pointer("bob").UserName,
user: UserFixtures.Pointer("bob"), user: UserFixtures.Pointer("bob"),
ClientName: "Bob", ClientName: "Bob",
ClientType: authn.ClientWebDAV, ClientType: authn.ClientPublic,
ClientURL: "", ClientURL: "",
CallbackURL: "", CallbackURL: "",
AuthMethod: authn.MethodBasic.String(), AuthMethod: authn.MethodOAuth2.String(),
AuthScope: "webdav files photos", AuthScope: "*",
AuthExpires: 0, AuthExpires: 0,
AuthTokens: -1, AuthTokens: -1,
AuthEnabled: false, AuthEnabled: false,

View File

@@ -368,7 +368,7 @@ func TestClient_SetFormValues(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
var values = form.Client{ClientName: "Annika", AuthMethod: authn.MethodBasic.String(), var values = form.Client{ClientName: "Annika", AuthMethod: authn.MethodOAuth2.String(),
AuthScope: "metrics", AuthScope: "metrics",
AuthExpires: -4000, AuthExpires: -4000,
AuthTokens: -5, AuthTokens: -5,
@@ -389,7 +389,7 @@ func TestClient_SetFormValues(t *testing.T) {
} }
var values = form.Client{ClientName: "Friend", var values = form.Client{ClientName: "Friend",
AuthMethod: authn.MethodBasic.String(), AuthMethod: authn.MethodOAuth2.String(),
AuthScope: "test", AuthScope: "test",
AuthExpires: 4000000, AuthExpires: 4000000,
AuthTokens: 3000000000, AuthTokens: 3000000000,

View File

@@ -40,12 +40,7 @@ func NewClientFromCli(ctx *cli.Context) Client {
f.ClientName = clean.Name(ctx.String("name")) f.ClientName = clean.Name(ctx.String("name"))
f.AuthScope = clean.Scope(ctx.String("scope")) f.AuthScope = clean.Scope(ctx.String("scope"))
f.AuthMethod = authn.Method(ctx.String("method")).String()
if method := clean.Scope(ctx.String("method")); authn.MethodOAuth2.Equal(method) {
f.AuthMethod = authn.MethodOAuth2.String()
} else if authn.MethodBasic.Equal(method) {
f.AuthMethod = authn.MethodBasic.String()
}
if authn.MethodOAuth2.NotEqual(f.AuthMethod) { if authn.MethodOAuth2.NotEqual(f.AuthMethod) {
f.AuthScope = "webdav" f.AuthScope = "webdav"

View File

@@ -3,6 +3,6 @@ package authn
// API client types. // API client types.
const ( const (
ClientConfidential = "confidential" ClientConfidential = "confidential"
ClientWebDAV = "webdav" ClientPublic = "public"
ClientUnknown = "" ClientUnknown = ""
) )

View File

@@ -13,10 +13,10 @@ type MethodType string
// Authentication methods. // Authentication methods.
const ( const (
MethodDefault MethodType = "default" MethodDefault MethodType = "default"
MethodBasic MethodType = "basic"
MethodAccessToken MethodType = "access_token" MethodAccessToken MethodType = "access_token"
MethodOAuth2 MethodType = "oauth2" MethodOAuth2 MethodType = "oauth2"
MethodOIDC MethodType = "oidc" MethodOIDC MethodType = "oidc"
Method2FA MethodType = "2fa"
MethodUnknown MethodType = "" MethodUnknown MethodType = ""
) )
@@ -30,10 +30,12 @@ func (t MethodType) String() string {
switch t { switch t {
case "": case "":
return string(MethodDefault) return string(MethodDefault)
case "openid":
return string(MethodOIDC)
case "oauth": case "oauth":
return string(MethodOAuth2) return string(MethodOAuth2)
case "openid":
return string(MethodOIDC)
case "totp":
return string(Method2FA)
default: default:
return string(t) return string(t)
} }
@@ -58,6 +60,8 @@ func (t MethodType) Pretty() string {
return "OAuth2" return "OAuth2"
case MethodOIDC: case MethodOIDC:
return "OIDC" return "OIDC"
case Method2FA:
return "2FA"
default: default:
return txt.UpperFirst(t.String()) return txt.UpperFirst(t.String())
} }
@@ -70,6 +74,10 @@ func Method(s string) MethodType {
return MethodDefault return MethodDefault
case "oauth2", "oauth": case "oauth2", "oauth":
return MethodOAuth2 return MethodOAuth2
case "sso":
return MethodOIDC
case "two-factor", "totp":
return Method2FA
default: default:
return MethodType(clean.TypeLower(s)) return MethodType(clean.TypeLower(s))
} }

View File

@@ -8,35 +8,35 @@ import (
func TestMethodType_String(t *testing.T) { func TestMethodType_String(t *testing.T) {
assert.Equal(t, "default", MethodDefault.String()) assert.Equal(t, "default", MethodDefault.String())
assert.Equal(t, "basic", MethodBasic.String())
assert.Equal(t, "access_token", MethodAccessToken.String()) assert.Equal(t, "access_token", MethodAccessToken.String())
assert.Equal(t, "oauth2", MethodOAuth2.String()) assert.Equal(t, "oauth2", MethodOAuth2.String())
assert.Equal(t, "oidc", MethodOIDC.String()) assert.Equal(t, "oidc", MethodOIDC.String())
assert.Equal(t, "2fa", Method2FA.String())
assert.Equal(t, "default", MethodUnknown.String()) assert.Equal(t, "default", MethodUnknown.String())
} }
func TestMethodType_IsDefault(t *testing.T) { func TestMethodType_IsDefault(t *testing.T) {
assert.Equal(t, true, MethodDefault.IsDefault()) assert.Equal(t, true, MethodDefault.IsDefault())
assert.Equal(t, false, MethodBasic.IsDefault())
assert.Equal(t, false, MethodAccessToken.IsDefault()) assert.Equal(t, false, MethodAccessToken.IsDefault())
assert.Equal(t, false, MethodOAuth2.IsDefault()) assert.Equal(t, false, MethodOAuth2.IsDefault())
assert.Equal(t, false, MethodOIDC.IsDefault()) assert.Equal(t, false, MethodOIDC.IsDefault())
assert.Equal(t, false, Method2FA.IsDefault())
assert.Equal(t, true, MethodUnknown.IsDefault()) assert.Equal(t, true, MethodUnknown.IsDefault())
} }
func TestMethodType_Pretty(t *testing.T) { func TestMethodType_Pretty(t *testing.T) {
assert.Equal(t, "Default", MethodDefault.Pretty()) assert.Equal(t, "Default", MethodDefault.Pretty())
assert.Equal(t, "Basic", MethodBasic.Pretty())
assert.Equal(t, "Access Token", MethodAccessToken.Pretty()) assert.Equal(t, "Access Token", MethodAccessToken.Pretty())
assert.Equal(t, "OAuth2", MethodOAuth2.Pretty()) assert.Equal(t, "OAuth2", MethodOAuth2.Pretty())
assert.Equal(t, "OIDC", MethodOIDC.Pretty()) assert.Equal(t, "OIDC", MethodOIDC.Pretty())
assert.Equal(t, "2FA", Method2FA.Pretty())
assert.Equal(t, "Default", MethodUnknown.Pretty()) assert.Equal(t, "Default", MethodUnknown.Pretty())
} }
func TestMethod(t *testing.T) { func TestMethod(t *testing.T) {
assert.Equal(t, MethodDefault, Method("default")) assert.Equal(t, MethodDefault, Method("default"))
assert.Equal(t, MethodBasic, Method("basic"))
assert.Equal(t, MethodAccessToken, Method("access_token")) assert.Equal(t, MethodAccessToken, Method("access_token"))
assert.Equal(t, MethodOAuth2, Method("oauth2")) assert.Equal(t, MethodOAuth2, Method("oauth2"))
assert.Equal(t, MethodOIDC, Method("oidc")) assert.Equal(t, MethodOIDC, Method("oidc"))
assert.Equal(t, Method2FA, Method("2fa"))
} }

View File

@@ -1,6 +1,9 @@
package rnd package rnd
import ( import (
"golang.org/x/text/cases"
"golang.org/x/text/language"
petname "github.com/dustinkirkland/golang-petname" petname "github.com/dustinkirkland/golang-petname"
) )
@@ -11,5 +14,5 @@ func Name() string {
// NameN returns a pronounceable name consisting of a random combination of adverbs, an adjective, and a pet name. // NameN returns a pronounceable name consisting of a random combination of adverbs, an adjective, and a pet name.
func NameN(n int) string { func NameN(n int) string {
return petname.Generate(n, "-") return cases.Title(language.English, cases.Compact).String(petname.Generate(n, " "))
} }

View File

@@ -1,18 +1,22 @@
package rnd package rnd
import ( import (
"strings"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestName(t *testing.T) { func TestName(t *testing.T) {
assert.NotEmpty(t, Name()) name := Name()
assert.NotEmpty(t, name)
assert.Equal(t, 1, strings.Count(name, " "))
for n := 0; n < 10; n++ { for n := 0; n < 10; n++ {
s := Name() s := Name()
t.Logf("Name %d: %s", n, s) t.Logf("Name %d: %s", n, s)
assert.NotEmpty(t, Name()) assert.NotEmpty(t, s)
assert.Equal(t, 1, strings.Count(s, " "))
} }
} }
@@ -23,11 +27,14 @@ func BenchmarkName(b *testing.B) {
} }
func TestNameN(t *testing.T) { func TestNameN(t *testing.T) {
assert.NotEmpty(t, NameN(2)) name := NameN(2)
assert.NotEmpty(t, name)
assert.Equal(t, 1, strings.Count(name, " "))
for n := 0; n < 10; n++ { for n := 0; n < 10; n++ {
s := NameN(n + 1) s := NameN(n + 1)
t.Logf("NameN %d: %s", n, s) t.Logf("NameN %d: %s", n, s)
assert.NotEmpty(t, Name()) assert.NotEmpty(t, s)
assert.Equal(t, n, strings.Count(s, " "))
} }
} }