OAuth2: Refactor "client add" and "client mod" CLI commands #808 #3943

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2024-01-29 13:54:50 +01:00
parent daca63f94e
commit 305e7bac68
38 changed files with 714 additions and 301 deletions

View File

@@ -11,6 +11,7 @@ import (
"github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/report" "github.com/photoprism/photoprism/pkg/report"
"github.com/photoprism/photoprism/pkg/rnd" "github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/unix"
) )
// AuthAddFlags specifies the "photoprism auth add" command flags. // AuthAddFlags specifies the "photoprism auth add" command flags.
@@ -26,7 +27,7 @@ var AuthAddFlags = []cli.Flag{
cli.Int64Flag{ cli.Int64Flag{
Name: "expires, e", Name: "expires, e",
Usage: "authentication `LIFETIME` in seconds, after which access expires (-1 to disable the limit)", Usage: "authentication `LIFETIME` in seconds, after which access expires (-1 to disable the limit)",
Value: entity.UnixYear, Value: unix.Year,
}, },
} }

View File

@@ -4,22 +4,25 @@ import (
"github.com/urfave/cli" "github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/acl" "github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/authn" "github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/unix"
) )
// Usage hints for the client management subcommands. // Usage hints for the client management subcommands.
const ( const (
ClientIdUsage = "static client `UID` for test purposes"
ClientSecretUsage = "static client `SECRET` for test purposes"
ClientNameUsage = "`CLIENT` name to help identify the application" ClientNameUsage = "`CLIENT` name to help identify the application"
ClientRoleUsage = "client authorization `ROLE`" ClientRoleUsage = "client authorization `ROLE`"
ClientAuthScope = "client authorization `SCOPES` e.g. \"metrics\" or \"photos albums\" (\"*\" to allow all)" ClientAuthScope = "client authorization `SCOPES` e.g. \"metrics\" or \"photos albums\" (\"*\" to allow all)"
ClientAuthProvider = "client authentication `PROVIDER`" ClientAuthProvider = "client authentication `PROVIDER`"
ClientAuthMethod = "client authentication `METHOD`" ClientAuthMethod = "client authentication `METHOD`"
ClientAuthExpires = "authentication `LIFETIME` in seconds, after which a new access token must be requested (-1 to disable the limit)" ClientAuthExpires = "access token `LIFETIME` in seconds, after which a new token must be requested"
ClientAuthTokens = "maximum 'NUMBER' of access tokens that the client can request (-1 to disable the limit)" ClientAuthTokens = "maximum `NUMBER` of access tokens that the client can request (-1 to disable the limit)"
ClientRegenerateSecret = "generate a new client secret and display it" ClientRegenerateSecret = "set a new randomly generated client secret"
ClientEnable = "enable client authentication if disabled" ClientEnable = "enable client authentication if disabled"
ClientDisable = "disable client authentication" ClientDisable = "disable client authentication"
ClientSecretInfo = "\nPLEASE WRITE DOWN THE %s CLIENT SECRET, AS YOU WILL NOT BE ABLE TO SEE IT AGAIN:\n"
) )
// ClientsCommands configures the client application subcommands. // ClientsCommands configures the client application subcommands.
@@ -39,6 +42,11 @@ var ClientsCommands = cli.Command{
// ClientAddFlags specifies the "photoprism client add" command flags. // ClientAddFlags specifies the "photoprism client add" command flags.
var ClientAddFlags = []cli.Flag{ var ClientAddFlags = []cli.Flag{
cli.StringFlag{
Name: "id",
Usage: ClientIdUsage,
Hidden: true,
},
cli.StringFlag{ cli.StringFlag{
Name: "name, n", Name: "name, n",
Usage: ClientNameUsage, Usage: ClientNameUsage,
@@ -67,13 +75,18 @@ var ClientAddFlags = []cli.Flag{
cli.Int64Flag{ cli.Int64Flag{
Name: "expires, e", Name: "expires, e",
Usage: ClientAuthExpires, Usage: ClientAuthExpires,
Value: entity.UnixDay, Value: unix.Day,
}, },
cli.Int64Flag{ cli.Int64Flag{
Name: "tokens, t", Name: "tokens, t",
Usage: ClientAuthTokens, Usage: ClientAuthTokens,
Value: 10, Value: 10,
}, },
cli.StringFlag{
Name: "secret",
Usage: ClientSecretUsage,
Hidden: true,
},
} }
// ClientModFlags specifies the "photoprism client mod" command flags. // ClientModFlags specifies the "photoprism client mod" command flags.
@@ -106,13 +119,20 @@ var ClientModFlags = []cli.Flag{
cli.Int64Flag{ cli.Int64Flag{
Name: "expires, e", Name: "expires, e",
Usage: ClientAuthExpires, Usage: ClientAuthExpires,
Value: unix.Day,
}, },
cli.Int64Flag{ cli.Int64Flag{
Name: "tokens, t", Name: "tokens, t",
Usage: ClientAuthTokens, Usage: ClientAuthTokens,
Value: 10,
},
cli.StringFlag{
Name: "secret",
Usage: ClientSecretUsage,
Hidden: true,
}, },
cli.BoolFlag{ cli.BoolFlag{
Name: "regenerate-secret, r", Name: "regenerate",
Usage: ClientRegenerateSecret, Usage: ClientRegenerateSecret,
}, },
cli.BoolFlag{ cli.BoolFlag{

View File

@@ -2,7 +2,6 @@ package commands
import ( import (
"fmt" "fmt"
"time"
"github.com/manifoldco/promptui" "github.com/manifoldco/promptui"
"github.com/urfave/cli" "github.com/urfave/cli"
@@ -20,7 +19,7 @@ import (
var ClientsAddCommand = cli.Command{ var ClientsAddCommand = cli.Command{
Name: "add", Name: "add",
Usage: "Registers a new client application", Usage: "Registers a new client application",
Description: "Specifying a username as argument will assign the client application to a registered user account.", Description: "If you specify a username as argument, the new client will belong to this user and inherit its privileges.",
ArgsUsage: "[username]", ArgsUsage: "[username]",
Flags: ClientAddFlags, Flags: ClientAddFlags,
Action: clientsAddAction, Action: clientsAddAction,
@@ -31,7 +30,7 @@ func clientsAddAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error { return CallWithDependencies(ctx, func(conf *config.Config) error {
conf.MigrateDb(false, nil) conf.MigrateDb(false, nil)
frm := form.NewClientFromCli(ctx) frm := form.AddClientFromCli(ctx)
interactive := true interactive := true
@@ -55,11 +54,6 @@ func clientsAddAction(ctx *cli.Context) error {
frm.ClientName = clean.Name(res) frm.ClientName = clean.Name(res)
} }
// Set a default client name if no specific name has been provided.
if frm.ClientName == "" {
frm.ClientName = time.Now().UTC().Format(time.DateTime)
}
if interactive && frm.AuthScope == "" { if interactive && frm.AuthScope == "" {
prompt := promptui.Prompt{ prompt := promptui.Prompt{
Label: "Authorization Scope", Label: "Authorization Scope",
@@ -82,23 +76,26 @@ func clientsAddAction(ctx *cli.Context) error {
client, addErr := entity.AddClient(frm) client, addErr := entity.AddClient(frm)
if addErr != nil { if addErr != nil {
return fmt.Errorf("failed to add client: %s", addErr) return addErr
} else { } else {
log.Infof("successfully registered new client %s", clean.LogQuote(client.ClientName)) log.Infof("successfully registered new client %s", clean.LogQuote(client.ClientName))
// Display client details. // Display client details.
cols := []string{"Client ID", "Client Name", "Authentication Method", "User", "Role", "Scope", "Enabled", "Authentication Expires", "Created At"} cols := []string{"Client ID", "Client Name", "Authentication Method", "User", "Role", "Scope", "Enabled", "Access Token Lifetime", "Created At"}
rows := make([][]string, 1) rows := make([][]string, 1)
var authExpires string var authExpires string
if client.AuthExpires > 0 { if client.AuthExpires > 0 {
authExpires = client.Expires().String() authExpires = client.Expires().String()
} else {
authExpires = report.Never
} }
if client.AuthTokens > 0 { if client.AuthTokens > 0 {
authExpires = fmt.Sprintf("%s; up to %d tokens", authExpires, client.AuthTokens) if authExpires != "" {
authExpires = fmt.Sprintf("%s; up to %d tokens", authExpires, client.AuthTokens)
} else {
authExpires = fmt.Sprintf("up to %d tokens", client.AuthTokens)
}
} }
rows[0] = []string{ rows[0] = []string{
@@ -118,16 +115,28 @@ func clientsAddAction(ctx *cli.Context) error {
} }
} }
if secret, err := client.NewSecret(); err != nil { // Se a random secret or the secret specified in the command flags, if any.
// Failed to create client secret. var secret, message string
return fmt.Errorf("failed to create client secret: %s", err) var err error
if secret = frm.Secret(); secret == "" {
secret, err = client.NewSecret()
message = fmt.Sprintf(ClientSecretInfo, "FOLLOWING RANDOMLY GENERATED")
} else { } else {
// Show client authentication credentials. err = client.SetSecret(secret)
fmt.Printf("\nPLEASE WRITE DOWN THE FOLLOWING RANDOMLY GENERATED CLIENT SECRET, AS YOU WILL NOT BE ABLE TO SEE IT AGAIN:\n") message = fmt.Sprintf(ClientSecretInfo, "SPECIFIED")
result := report.Credentials("Client ID", client.ClientUID, "Client Secret", secret)
fmt.Printf("\n%s\n", result)
} }
// Check if the secret has been saved successfully or return an error otherwise.
if err != nil {
return fmt.Errorf("failed to set client secret: %s", err)
}
// Show client authentication credentials.
fmt.Printf(message)
result := report.Credentials("Client ID", client.ClientUID, "Client Secret", secret)
fmt.Printf("\n%s\n", result)
return nil return nil
}) })
} }

View File

@@ -23,7 +23,7 @@ var ClientsListCommand = cli.Command{
// clientsListAction lists registered client applications // clientsListAction lists registered client applications
func clientsListAction(ctx *cli.Context) error { func clientsListAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error { return CallWithDependencies(ctx, func(conf *config.Config) error {
cols := []string{"Client ID", "Client Name", "Authentication Method", "User", "Role", "Scope", "Enabled", "Authentication Expires", "Created At"} cols := []string{"Client ID", "Client Name", "Authentication Method", "User", "Role", "Scope", "Enabled", "Access Token Lifetime", "Created At"}
// Fetch clients from database. // Fetch clients from database.
clients, err := query.Clients(ctx.Int("n"), 0, "", ctx.Args().First()) clients, err := query.Clients(ctx.Int("n"), 0, "", ctx.Args().First())
@@ -45,14 +45,17 @@ func clientsListAction(ctx *cli.Context) error {
// Display report. // Display report.
for i, client := range clients { for i, client := range clients {
var authExpires string var authExpires string
if client.AuthExpires > 0 { if client.AuthExpires > 0 {
authExpires = client.Expires().String() authExpires = client.Expires().String()
} else {
authExpires = report.Never
} }
if client.AuthTokens > 0 { if client.AuthTokens > 0 {
authExpires = fmt.Sprintf("%s; up to %d tokens", authExpires, client.AuthTokens) if authExpires != "" {
authExpires = fmt.Sprintf("%s; up to %d tokens", authExpires, client.AuthTokens)
} else {
authExpires = fmt.Sprintf("up to %d tokens", client.AuthTokens)
}
} }
rows[i] = []string{ rows[i] = []string{

View File

@@ -62,7 +62,7 @@ func TestClientsListCommand(t *testing.T) {
// Check command output for plausibility. // Check command output for plausibility.
// t.Logf(output) // t.Logf(output)
assert.NoError(t, err) assert.NoError(t, err)
assert.Contains(t, output, "Client ID;Client Name;Authentication Method;User;Role;Scope;Enabled;Authentication Expires;Created At") assert.Contains(t, output, "Client ID;Client Name;Authentication Method;User;Role;Scope;Enabled;Access Token Lifetime;Created At")
assert.Contains(t, output, "Monitoring") assert.Contains(t, output, "Monitoring")
assert.Contains(t, output, "metrics") assert.Contains(t, output, "metrics")
assert.NotContains(t, output, "bob") assert.NotContains(t, output, "bob")

View File

@@ -7,7 +7,7 @@ import (
"github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/authn" "github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/report" "github.com/photoprism/photoprism/pkg/report"
) )
@@ -26,10 +26,10 @@ func clientsModAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error { return CallWithDependencies(ctx, func(conf *config.Config) error {
conf.MigrateDb(false, nil) conf.MigrateDb(false, nil)
id := clean.UID(ctx.Args().First()) frm := form.ModClientFromCli(ctx)
// Name or UID provided? // Name or UID provided?
if id == "" { if frm.ID() == "" {
log.Infof("no valid client id specified") log.Infof("no valid client id specified")
return cli.ShowSubcommandHelp(ctx) return cli.ShowSubcommandHelp(ctx)
} }
@@ -37,73 +37,58 @@ func clientsModAction(ctx *cli.Context) error {
// Find client record. // Find client record.
var client *entity.Client var client *entity.Client
client = entity.FindClientByUID(id) client = entity.FindClientByUID(frm.ID())
if client == nil { if client == nil {
return fmt.Errorf("client %s not found", clean.LogQuote(id)) return fmt.Errorf("client %s not found", clean.LogQuote(frm.ID()))
} }
if name := clean.Name(ctx.String("name")); name != "" { // Update client from form values.
client.ClientName = name client.SetFormValues(frm)
}
if ctx.IsSet("method") { if ctx.IsSet("enable") || ctx.IsSet("disable") {
client.AuthMethod = authn.Method(ctx.String("method")).String() client.AuthEnabled = frm.AuthEnabled
}
if ctx.IsSet("scope") {
client.SetScope(ctx.String("scope"))
}
if expires := ctx.Int64("expires"); expires != 0 {
if expires > entity.UnixMonth {
client.AuthExpires = entity.UnixMonth
} else if expires > 0 {
client.AuthExpires = expires
} else if expires <= 0 {
client.AuthExpires = entity.UnixHour
}
}
if tokens := ctx.Int64("tokens"); tokens != 0 {
if tokens > 2147483647 {
client.AuthTokens = 2147483647
} else if tokens > 0 {
client.AuthTokens = tokens
} else if tokens < 0 {
client.AuthTokens = -1
}
}
if ctx.IsSet("disable") && ctx.Bool("disable") {
client.AuthEnabled = false
log.Infof("disabled client authentication") log.Infof("disabled client authentication")
} else if ctx.IsSet("enable") && ctx.Bool("enable") {
client.AuthEnabled = true
log.Warnf("enabled client authentication")
} }
// Save changes. if client.AuthEnabled {
log.Infof("client authentication is enabled")
} else {
log.Warnf("client authentication is disabled")
}
// Update client record if valid.
if err := client.Validate(); err != nil { if err := client.Validate(); err != nil {
return fmt.Errorf("invalid client settings: %s", err) return fmt.Errorf("invalid values: %s", err)
} else if err = client.Save(); err != nil { } else if err = client.Save(); err != nil {
return fmt.Errorf("failed to update client settings: %s", err) return err
} else { } else {
log.Infof("client %s has been updated", clean.LogQuote(client.ClientName)) log.Infof("client %s has been updated", clean.LogQuote(client.ClientName))
} }
// Regenerate and display secret, if requested. // Change client secret if requested.
if ctx.IsSet("regenerate-secret") && ctx.Bool("regenerate-secret") { var secret, message string
if secret, err := client.NewSecret(); err != nil { var err error
// Failed to create client secret.
return fmt.Errorf("failed to create client secret: %s", err) if ctx.IsSet("regenerate") && ctx.Bool("regenerate") {
} else { if secret, err = client.NewSecret(); err != nil {
// Show client authentication credentials. return fmt.Errorf("failed to regenerate client secret: %s", err)
fmt.Printf("\nTHE FOLLOWING RANDOMLY GENERATED CLIENT ID AND SECRET ARE REQUIRED FOR AUTHENTICATION:\n")
result := report.Credentials("Client ID", client.ClientUID, "Client Secret", secret)
fmt.Printf("\n%s", result)
fmt.Printf("\nPLEASE WRITE THE CREDENTIALS DOWN AND KEEP THEM IN A SAFE PLACE, AS THE SECRET CANNOT BE DISPLAYED AGAIN.\n\n")
} }
message = fmt.Sprintf(ClientSecretInfo, "FOLLOWING RANDOMLY GENERATED")
} else if secret = frm.Secret(); secret == "" {
log.Debugf("client secret remains unchanged")
} else if err = client.SetSecret(secret); err != nil {
return fmt.Errorf("failed to set client secret: %s", err)
} else {
message = fmt.Sprintf(ClientSecretInfo, "NEW")
}
// Show new client secret.
if secret != "" && err == nil {
fmt.Printf(message)
result := report.Credentials("Client ID", client.ClientUID, "Client Secret", secret)
fmt.Printf("\n%s\n", result)
} }
return nil return nil

View File

@@ -14,6 +14,7 @@ import (
"github.com/photoprism/photoprism/pkg/authn" "github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/rnd" "github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/unix"
) )
// ClientUID is the unique ID prefix. // ClientUID is the unique ID prefix.
@@ -64,7 +65,7 @@ func NewClient() *Client {
AuthProvider: authn.ProviderClientCredentials.String(), AuthProvider: authn.ProviderClientCredentials.String(),
AuthMethod: authn.MethodOAuth2.String(), AuthMethod: authn.MethodOAuth2.String(),
AuthScope: "", AuthScope: "",
AuthExpires: UnixHour, AuthExpires: unix.Hour,
AuthTokens: 5, AuthTokens: 5,
AuthEnabled: true, AuthEnabled: true,
LastActive: 0, LastActive: 0,
@@ -113,9 +114,20 @@ func (m *Client) Name() string {
return m.ClientName return m.ClientName
} }
// SetName sets a custom client name.
func (m *Client) SetName(s string) *Client {
if s = clean.Name(s); s != "" {
m.ClientName = s
}
return m
}
// SetRole sets the client role specified as string. // SetRole sets the client role specified as string.
func (m *Client) SetRole(role string) *Client { func (m *Client) SetRole(role string) *Client {
m.ClientRole = acl.ClientRoles[clean.Role(role)].String() if role != "" {
m.ClientRole = acl.ClientRoles[clean.Role(role)].String()
}
return m return m
} }
@@ -173,7 +185,7 @@ func (m *Client) UserInfo() string {
if m == nil { if m == nil {
return "" return ""
} else if m.UserUID == "" { } else if m.UserUID == "" {
return "" return "n/a"
} else if m.UserName != "" { } else if m.UserName != "" {
return m.UserName return m.UserName
} }
@@ -242,21 +254,36 @@ func (m *Client) Updates(values interface{}) error {
return UnscopedDb().Model(m).Updates(values).Error return UnscopedDb().Model(m).Updates(values).Error
} }
// NewSecret sets a new secret stored as hash. // NewSecret sets a random client secret and returns it if successful.
func (m *Client) NewSecret() (s string, err error) { func (m *Client) NewSecret() (secret string, err error) {
if !m.HasUID() { if !m.HasUID() {
return "", fmt.Errorf("invalid client uid") return "", fmt.Errorf("invalid client uid")
} }
s = rnd.Base62(32) secret = rnd.ClientSecret()
pw := NewPassword(m.ClientUID, s, false) if err = m.SetSecret(secret); err != nil {
if err = pw.Save(); err != nil {
return "", err return "", err
} }
return s, nil return secret, nil
}
// SetSecret updates the current client secret or returns an error otherwise.
func (m *Client) SetSecret(secret string) (err error) {
if !m.HasUID() {
return fmt.Errorf("invalid client uid")
} else if !rnd.IsClientSecret(secret) {
return fmt.Errorf("invalid client secret")
}
pw := NewPassword(m.ClientUID, secret, false)
if err = pw.Save(); err != nil {
return err
}
return nil
} }
// HasSecret checks if the given client secret is correct. // HasSecret checks if the given client secret is correct.
@@ -296,19 +323,37 @@ func (m *Client) Provider() authn.ProviderType {
return authn.Provider(m.AuthProvider) return authn.Provider(m.AuthProvider)
} }
// SetProvider sets a custom client authentication provider.
func (m *Client) SetProvider(provider authn.ProviderType) *Client {
if !provider.IsDefault() {
m.AuthProvider = provider.String()
}
return m
}
// Method returns the client authentication method. // Method returns the client authentication method.
func (m *Client) Method() authn.MethodType { func (m *Client) Method() authn.MethodType {
return authn.Method(m.AuthMethod) return authn.Method(m.AuthMethod)
} }
// SetMethod sets a custom client authentication method.
func (m *Client) SetMethod(method authn.MethodType) *Client {
if !method.IsDefault() {
m.AuthMethod = method.String()
}
return m
}
// Scope returns the client authorization scope. // Scope returns the client authorization scope.
func (m *Client) Scope() string { func (m *Client) Scope() string {
return clean.Scope(m.AuthScope) return clean.Scope(m.AuthScope)
} }
// SetScope sets the client authorization scope. // SetScope sets a custom client authorization scope.
func (m *Client) SetScope(s string) *Client { func (m *Client) SetScope(s string) *Client {
m.AuthScope = clean.Scope(s) if s = clean.Scope(s); s != "" {
m.AuthScope = clean.Scope(s)
}
return m return m
} }
@@ -318,7 +363,7 @@ func (m *Client) UpdateLastActive() *Client {
return m return m
} }
m.LastActive = UnixTime() m.LastActive = unix.Time()
if err := Db().Model(m).UpdateColumn("LastActive", m.LastActive).Error; err != nil { if err := Db().Model(m).UpdateColumn("LastActive", m.LastActive).Error; err != nil {
log.Debugf("client: failed to update %s timestamp (%s)", m.ClientUID, err) log.Debugf("client: failed to update %s timestamp (%s)", m.ClientUID, err)
@@ -351,6 +396,29 @@ func (m *Client) Expires() time.Duration {
return time.Duration(m.AuthExpires) * time.Second return time.Duration(m.AuthExpires) * time.Second
} }
// SetExpires sets a custom auth expiration time in seconds.
func (m *Client) SetExpires(i int64) *Client {
if i != 0 {
m.AuthExpires = i
}
return m
}
// Tokens returns maximum number of access tokens this client can create.
func (m *Client) Tokens() time.Duration {
return time.Duration(m.AuthExpires) * time.Second
}
// SetTokens sets a custom access token limit for this client.
func (m *Client) SetTokens(i int64) *Client {
if i != 0 {
m.AuthTokens = i
}
return m
}
// Report returns the entity values as rows. // Report returns the entity values as rows.
func (m *Client) Report(skipEmpty bool) (rows [][]string, cols []string) { func (m *Client) Report(skipEmpty bool) (rows [][]string, cols []string) {
cols = []string{"Name", "Value"} cols = []string{"Name", "Value"}
@@ -380,51 +448,50 @@ func (m *Client) Report(skipEmpty bool) (rows [][]string, cols []string) {
// SetFormValues sets the values specified in the form. // SetFormValues sets the values specified in the form.
func (m *Client) SetFormValues(frm form.Client) *Client { func (m *Client) SetFormValues(frm form.Client) *Client {
if frm.UserUID == "" && frm.UserName == "" { if frm.UserUID == "" && frm.UserName == "" {
// Ignore. // Client does not belong to a specific user or the user remains unchanged.
} else if u := FindUser(User{UserUID: frm.UserUID, UserName: frm.UserName}); u != nil { } else if u := FindUser(User{UserUID: frm.UserUID, UserName: frm.UserName}); u != nil {
m.SetUser(u) m.SetUser(u)
} }
if frm.ClientName != "" { // Set custom client UID?
m.ClientName = frm.Name() if id := frm.ID(); m.ClientUID == "" && id != "" {
m.ClientUID = id
} }
if frm.ClientRole != "" { // Set values from form.
m.SetRole(frm.ClientRole) m.SetName(frm.Name())
} m.SetProvider(frm.Provider())
m.SetMethod(frm.Method())
if frm.AuthProvider != "" { m.SetScope(frm.Scope())
m.AuthProvider = frm.Provider().String() m.SetTokens(frm.Tokens())
} m.SetExpires(frm.Expires())
if frm.AuthMethod != "" {
m.AuthMethod = frm.Method().String()
}
if frm.AuthScope != "" {
m.SetScope(frm.AuthScope)
}
if frm.AuthExpires > UnixMonth {
m.AuthExpires = UnixMonth
} else if frm.AuthExpires > 0 {
m.AuthExpires = frm.AuthExpires
} else if m.AuthExpires <= 0 {
m.AuthExpires = UnixHour
}
if frm.AuthTokens > 2147483647 {
m.AuthTokens = 2147483647
} else if frm.AuthTokens > 0 {
m.AuthTokens = frm.AuthTokens
} else if m.AuthTokens < 0 {
m.AuthTokens = -1
}
// Enable authentication?
if frm.AuthEnabled { if frm.AuthEnabled {
m.AuthEnabled = true m.AuthEnabled = true
} }
// Replace empty values with defaults.
if m.AuthProvider == "" {
m.AuthProvider = authn.ProviderClientCredentials.String()
}
if m.AuthMethod == "" {
m.AuthMethod = authn.MethodOAuth2.String()
}
if m.AuthScope == "" {
m.AuthScope = "*"
}
if m.AuthExpires <= 0 {
m.AuthExpires = unix.Hour
}
if m.AuthTokens <= 0 {
m.AuthTokens = -1
}
return m return m
} }

View File

@@ -1,11 +1,17 @@
package entity package entity
import ( import (
"fmt"
"github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/internal/form"
) )
// AddClient creates a new client and returns it if successful. // AddClient creates a new client and returns it if successful.
func AddClient(frm form.Client) (client *Client, err error) { func AddClient(frm form.Client) (client *Client, err error) {
if found := FindClientByUID(frm.ID()); found != nil {
return found, fmt.Errorf("client id %s already exists", found.ClientUID)
}
client = NewClient().SetFormValues(frm) client = NewClient().SetFormValues(frm)
if err = client.Validate(); err != nil { if err = client.Validate(); err != nil {

View File

@@ -3,6 +3,7 @@ package entity
import ( import (
"github.com/photoprism/photoprism/internal/acl" "github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/pkg/authn" "github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/unix"
) )
type ClientMap map[string]Client type ClientMap map[string]Client
@@ -37,7 +38,7 @@ var ClientFixtures = ClientMap{
AuthProvider: authn.ProviderClientCredentials.String(), AuthProvider: authn.ProviderClientCredentials.String(),
AuthMethod: authn.MethodOAuth2.String(), AuthMethod: authn.MethodOAuth2.String(),
AuthScope: "*", AuthScope: "*",
AuthExpires: UnixDay, AuthExpires: unix.Day,
AuthTokens: -1, AuthTokens: -1,
AuthEnabled: true, AuthEnabled: true,
LastActive: 0, LastActive: 0,
@@ -73,7 +74,7 @@ var ClientFixtures = ClientMap{
AuthProvider: authn.ProviderClientCredentials.String(), AuthProvider: authn.ProviderClientCredentials.String(),
AuthMethod: authn.MethodOAuth2.String(), AuthMethod: authn.MethodOAuth2.String(),
AuthScope: "metrics", AuthScope: "metrics",
AuthExpires: UnixHour, AuthExpires: unix.Hour,
AuthTokens: 2, AuthTokens: 2,
AuthEnabled: true, AuthEnabled: true,
LastActive: 0, LastActive: 0,
@@ -91,7 +92,7 @@ var ClientFixtures = ClientMap{
AuthProvider: authn.ProviderClientCredentials.String(), AuthProvider: authn.ProviderClientCredentials.String(),
AuthMethod: authn.MethodUnknown.String(), AuthMethod: authn.MethodUnknown.String(),
AuthScope: "*", AuthScope: "*",
AuthExpires: UnixHour, AuthExpires: unix.Hour,
AuthTokens: 2, AuthTokens: 2,
AuthEnabled: true, AuthEnabled: true,
LastActive: 0, LastActive: 0,
@@ -109,7 +110,7 @@ var ClientFixtures = ClientMap{
AuthProvider: authn.ProviderClientCredentials.String(), AuthProvider: authn.ProviderClientCredentials.String(),
AuthMethod: authn.MethodOAuth2.String(), AuthMethod: authn.MethodOAuth2.String(),
AuthScope: "metrics", AuthScope: "metrics",
AuthExpires: UnixHour, AuthExpires: unix.Hour,
AuthTokens: 2, AuthTokens: 2,
AuthEnabled: true, AuthEnabled: true,
LastActive: 0, LastActive: 0,
@@ -128,7 +129,7 @@ var ClientFixtures = ClientMap{
AuthProvider: authn.ProviderClientCredentials.String(), AuthProvider: authn.ProviderClientCredentials.String(),
AuthMethod: authn.MethodOAuth2.String(), AuthMethod: authn.MethodOAuth2.String(),
AuthScope: "statistics", AuthScope: "statistics",
AuthExpires: UnixHour, AuthExpires: unix.Hour,
AuthTokens: 2, AuthTokens: 2,
AuthEnabled: true, AuthEnabled: true,
LastActive: 0, LastActive: 0,

View File

@@ -380,13 +380,13 @@ func TestClient_Expires(t *testing.T) {
func TestClient_UserInfo(t *testing.T) { func TestClient_UserInfo(t *testing.T) {
t.Run("New", func(t *testing.T) { t.Run("New", func(t *testing.T) {
assert.Equal(t, "", NewClient().UserInfo()) assert.Equal(t, "n/a", NewClient().UserInfo())
}) })
t.Run("Alice", func(t *testing.T) { t.Run("Alice", func(t *testing.T) {
assert.Equal(t, "alice", ClientFixtures.Pointer("alice").UserInfo()) assert.Equal(t, "alice", ClientFixtures.Pointer("alice").UserInfo())
}) })
t.Run("Metrics", func(t *testing.T) { t.Run("Metrics", func(t *testing.T) {
assert.Equal(t, "", ClientFixtures.Pointer("metrics").UserInfo()) assert.Equal(t, "n/a", ClientFixtures.Pointer("metrics").UserInfo())
}) })
} }

View File

@@ -19,6 +19,7 @@ import (
"github.com/photoprism/photoprism/pkg/list" "github.com/photoprism/photoprism/pkg/list"
"github.com/photoprism/photoprism/pkg/rnd" "github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/txt" "github.com/photoprism/photoprism/pkg/txt"
"github.com/photoprism/photoprism/pkg/unix"
) )
// SessionPrefix for RefID. // SessionPrefix for RefID.
@@ -101,7 +102,7 @@ func (m *Session) Expires(t time.Time) *Session {
func DeleteExpiredSessions() (deleted int) { func DeleteExpiredSessions() (deleted int) {
found := Sessions{} found := Sessions{}
if err := Db().Where("sess_expires > 0 AND sess_expires < ?", UnixTime()).Find(&found).Error; err != nil { if err := Db().Where("sess_expires > 0 AND sess_expires < ?", unix.Time()).Find(&found).Error; err != nil {
event.AuditErr([]string{"failed to fetch expired sessions", "%s"}, err) event.AuditErr([]string{"failed to fetch expired sessions", "%s"}, err)
return deleted return deleted
} }
@@ -782,7 +783,7 @@ func (m *Session) ExpiresIn() int64 {
return 0 return 0
} }
return m.SessExpires - UnixTime() return m.SessExpires - unix.Time()
} }
// TimeoutAt returns the time at which the session will expire due to inactivity. // TimeoutAt returns the time at which the session will expire due to inactivity.
@@ -822,7 +823,7 @@ func (m *Session) UpdateLastActive() *Session {
return m return m
} }
m.LastActive = UnixTime() m.LastActive = unix.Time()
if err := Db().Model(m).UpdateColumn("LastActive", m.LastActive).Error; err != nil { if err := Db().Model(m).UpdateColumn("LastActive", m.LastActive).Error; err != nil {
event.AuditWarn([]string{m.IP(), "session %s", "failed to update last active time", "%s"}, m.RefID, err) event.AuditWarn([]string{m.IP(), "session %s", "failed to update last active time", "%s"}, m.RefID, err)

View File

@@ -9,6 +9,7 @@ import (
"github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/rnd" "github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/unix"
) )
// Create a new session cache with an expiration time of 15 minutes. // Create a new session cache with an expiration time of 15 minutes.
@@ -31,7 +32,7 @@ func FindSession(id string) (*Session, error) {
// Find the session in the cache with a fallback to the database. // Find the session in the cache with a fallback to the database.
if cacheData, ok := sessionCache.Get(id); ok && cacheData != nil { if cacheData, ok := sessionCache.Get(id); ok && cacheData != nil {
if cached := cacheData.(*Session); !cached.Expired() { if cached := cacheData.(*Session); !cached.Expired() {
cached.LastActive = UnixTime() cached.LastActive = unix.Time()
return cached, nil return cached, nil
} else if err := cached.Delete(); err != nil { } else if err := cached.Delete(); err != nil {
event.AuditErr([]string{cached.IP(), "session %s", "failed to delete after expiration", "%s"}, cached.RefID, err) event.AuditErr([]string{cached.IP(), "session %s", "failed to delete after expiration", "%s"}, cached.RefID, err)

View File

@@ -4,11 +4,13 @@ import (
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/pkg/unix"
) )
func TestNewClientAuthentication(t *testing.T) { func TestNewClientAuthentication(t *testing.T) {
t.Run("Anonymous", func(t *testing.T) { t.Run("Anonymous", func(t *testing.T) {
sess := NewClientAuthentication("Anonymous", UnixDay, "metrics", nil) sess := NewClientAuthentication("Anonymous", unix.Day, "metrics", nil)
if sess == nil { if sess == nil {
t.Fatal("session must not be nil") t.Fatal("session must not be nil")
@@ -23,7 +25,7 @@ func TestNewClientAuthentication(t *testing.T) {
t.Fatal("user must not be nil") t.Fatal("user must not be nil")
} }
sess := NewClientAuthentication("alice", UnixDay, "metrics", user) sess := NewClientAuthentication("alice", unix.Day, "metrics", user)
if sess == nil { if sess == nil {
t.Fatal("session must not be nil") t.Fatal("session must not be nil")
@@ -38,7 +40,7 @@ func TestNewClientAuthentication(t *testing.T) {
t.Fatal("user must not be nil") t.Fatal("user must not be nil")
} }
sess := NewClientAuthentication("alice", UnixDay, "", user) sess := NewClientAuthentication("alice", unix.Day, "", user)
if sess == nil { if sess == nil {
t.Fatal("session must not be nil") t.Fatal("session must not be nil")
@@ -65,7 +67,7 @@ func TestNewClientAuthentication(t *testing.T) {
func TestAddClientAuthentication(t *testing.T) { func TestAddClientAuthentication(t *testing.T) {
t.Run("Anonymous", func(t *testing.T) { t.Run("Anonymous", func(t *testing.T) {
sess, err := AddClientAuthentication("", UnixDay, "metrics", nil) sess, err := AddClientAuthentication("", unix.Day, "metrics", nil)
assert.NoError(t, err) assert.NoError(t, err)
@@ -82,7 +84,7 @@ func TestAddClientAuthentication(t *testing.T) {
t.Fatal("user must not be nil") t.Fatal("user must not be nil")
} }
sess, err := AddClientAuthentication("My Client App Token", UnixDay, "metrics", user) sess, err := AddClientAuthentication("My Client App Token", unix.Day, "metrics", user)
assert.NoError(t, err) assert.NoError(t, err)

View File

@@ -4,6 +4,7 @@ import (
"github.com/photoprism/photoprism/pkg/authn" "github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/rnd" "github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/unix"
) )
type SessionMap map[string]Session type SessionMap map[string]Session
@@ -29,8 +30,8 @@ var SessionFixtures = SessionMap{
authToken: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", authToken: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0",
ID: rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0"), ID: rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0"),
RefID: "sessxkkcabcd", RefID: "sessxkkcabcd",
SessTimeout: UnixDay * 3, SessTimeout: unix.Day * 3,
SessExpires: UnixTime() + UnixWeek, SessExpires: unix.Time() + unix.Week,
user: UserFixtures.Pointer("alice"), user: UserFixtures.Pointer("alice"),
UserUID: UserFixtures.Pointer("alice").UserUID, UserUID: UserFixtures.Pointer("alice").UserUID,
UserName: UserFixtures.Pointer("alice").UserName, UserName: UserFixtures.Pointer("alice").UserName,
@@ -40,7 +41,7 @@ var SessionFixtures = SessionMap{
ID: rnd.SessionID("bb8658e779403ae524a188712470060f050054324a8b104e"), ID: rnd.SessionID("bb8658e779403ae524a188712470060f050054324a8b104e"),
RefID: "sess34q3hael", RefID: "sess34q3hael",
SessTimeout: -1, SessTimeout: -1,
SessExpires: UnixTime() + UnixDay, SessExpires: unix.Time() + unix.Day,
AuthScope: clean.Scope("*"), AuthScope: clean.Scope("*"),
AuthProvider: authn.ProviderAccessToken.String(), AuthProvider: authn.ProviderAccessToken.String(),
AuthMethod: authn.MethodDefault.String(), AuthMethod: authn.MethodDefault.String(),
@@ -55,7 +56,7 @@ var SessionFixtures = SessionMap{
ID: rnd.SessionID("DIbS8T-uyGMe1-R3fmTv-vVaR35"), ID: rnd.SessionID("DIbS8T-uyGMe1-R3fmTv-vVaR35"),
RefID: "sess6ey1ykya", RefID: "sess6ey1ykya",
SessTimeout: -1, SessTimeout: -1,
SessExpires: UnixTime() + UnixDay, SessExpires: unix.Time() + unix.Day,
AuthScope: clean.Scope("*"), AuthScope: clean.Scope("*"),
AuthProvider: authn.ProviderAccessToken.String(), AuthProvider: authn.ProviderAccessToken.String(),
AuthMethod: authn.MethodPersonal.String(), AuthMethod: authn.MethodPersonal.String(),
@@ -70,7 +71,7 @@ var SessionFixtures = SessionMap{
ID: rnd.SessionID("5d0rGx-EvsDnV-DcKtYY-HT1aWL"), ID: rnd.SessionID("5d0rGx-EvsDnV-DcKtYY-HT1aWL"),
RefID: "sesshjtgx8qt", RefID: "sesshjtgx8qt",
SessTimeout: -1, SessTimeout: -1,
SessExpires: UnixTime() + UnixDay, SessExpires: unix.Time() + unix.Day,
AuthScope: clean.Scope("webdav"), AuthScope: clean.Scope("webdav"),
AuthProvider: authn.ProviderAccessToken.String(), AuthProvider: authn.ProviderAccessToken.String(),
AuthMethod: authn.MethodPersonal.String(), AuthMethod: authn.MethodPersonal.String(),
@@ -85,7 +86,7 @@ var SessionFixtures = SessionMap{
ID: rnd.SessionID("778f0f7d80579a072836c65b786145d6e0127505194cc51e"), ID: rnd.SessionID("778f0f7d80579a072836c65b786145d6e0127505194cc51e"),
RefID: "sessjr0ge18d", RefID: "sessjr0ge18d",
SessTimeout: 0, SessTimeout: 0,
SessExpires: UnixTime() + UnixDay, SessExpires: unix.Time() + unix.Day,
AuthScope: clean.Scope("metrics photos albums videos"), AuthScope: clean.Scope("metrics photos albums videos"),
AuthProvider: authn.ProviderAccessToken.String(), AuthProvider: authn.ProviderAccessToken.String(),
AuthMethod: authn.MethodDefault.String(), AuthMethod: authn.MethodDefault.String(),
@@ -100,8 +101,8 @@ var SessionFixtures = SessionMap{
authToken: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1", authToken: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1",
ID: rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1"), ID: rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1"),
RefID: "sessxkkcabce", RefID: "sessxkkcabce",
SessTimeout: UnixDay * 3, SessTimeout: unix.Day * 3,
SessExpires: UnixTime() + UnixWeek, SessExpires: unix.Time() + unix.Week,
user: UserFixtures.Pointer("bob"), user: UserFixtures.Pointer("bob"),
UserUID: UserFixtures.Pointer("bob").UserUID, UserUID: UserFixtures.Pointer("bob").UserUID,
UserName: UserFixtures.Pointer("bob").UserName, UserName: UserFixtures.Pointer("bob").UserName,
@@ -110,8 +111,8 @@ var SessionFixtures = SessionMap{
authToken: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac2", authToken: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac2",
ID: rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac2"), ID: rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac2"),
RefID: "sessxkkcabcf", RefID: "sessxkkcabcf",
SessTimeout: UnixDay * 3, SessTimeout: unix.Day * 3,
SessExpires: UnixTime() + UnixWeek, SessExpires: unix.Time() + unix.Week,
user: UserFixtures.Pointer("unauthorized"), user: UserFixtures.Pointer("unauthorized"),
UserUID: UserFixtures.Pointer("unauthorized").UserUID, UserUID: UserFixtures.Pointer("unauthorized").UserUID,
UserName: UserFixtures.Pointer("unauthorized").UserName, UserName: UserFixtures.Pointer("unauthorized").UserName,
@@ -120,8 +121,8 @@ var SessionFixtures = SessionMap{
authToken: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac3", authToken: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac3",
ID: rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac3"), ID: rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac3"),
RefID: "sessxkkcabcg", RefID: "sessxkkcabcg",
SessTimeout: UnixDay * 3, SessTimeout: unix.Day * 3,
SessExpires: UnixTime() + UnixWeek, SessExpires: unix.Time() + unix.Week,
user: &Visitor, user: &Visitor,
UserUID: Visitor.UserUID, UserUID: Visitor.UserUID,
UserName: Visitor.UserName, UserName: Visitor.UserName,
@@ -136,7 +137,7 @@ var SessionFixtures = SessionMap{
ID: rnd.SessionID("4ebe1048a7384e1e6af2930b5b6f29795ffab691df47a488"), ID: rnd.SessionID("4ebe1048a7384e1e6af2930b5b6f29795ffab691df47a488"),
RefID: "sessaae5cxun", RefID: "sessaae5cxun",
SessTimeout: 0, SessTimeout: 0,
SessExpires: UnixTime() + UnixWeek, SessExpires: unix.Time() + unix.Week,
AuthScope: clean.Scope("metrics"), AuthScope: clean.Scope("metrics"),
AuthProvider: authn.ProviderAccessToken.String(), AuthProvider: authn.ProviderAccessToken.String(),
AuthMethod: authn.MethodDefault.String(), AuthMethod: authn.MethodDefault.String(),
@@ -149,8 +150,8 @@ var SessionFixtures = SessionMap{
authToken: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac4", authToken: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac4",
ID: rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac4"), ID: rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac4"),
RefID: "sessxkkcabch", RefID: "sessxkkcabch",
SessTimeout: UnixDay * 3, SessTimeout: unix.Day * 3,
SessExpires: UnixTime() + UnixWeek, SessExpires: unix.Time() + unix.Week,
user: UserFixtures.Pointer("friend"), user: UserFixtures.Pointer("friend"),
UserUID: UserFixtures.Pointer("friend").UserUID, UserUID: UserFixtures.Pointer("friend").UserUID,
UserName: UserFixtures.Pointer("friend").UserName, UserName: UserFixtures.Pointer("friend").UserName,
@@ -160,7 +161,7 @@ var SessionFixtures = SessionMap{
ID: rnd.SessionID("9d8b8801ffa23eb52e08ca7766283799ddfd8dd368212345"), ID: rnd.SessionID("9d8b8801ffa23eb52e08ca7766283799ddfd8dd368212345"),
RefID: "sessgh612345", RefID: "sessgh612345",
SessTimeout: 0, SessTimeout: 0,
SessExpires: UnixTime() + UnixWeek, SessExpires: unix.Time() + unix.Week,
AuthScope: clean.Scope("metrics"), AuthScope: clean.Scope("metrics"),
AuthProvider: authn.ProviderClientCredentials.String(), AuthProvider: authn.ProviderClientCredentials.String(),
AuthMethod: authn.MethodOAuth2.String(), AuthMethod: authn.MethodOAuth2.String(),
@@ -177,7 +178,7 @@ var SessionFixtures = SessionMap{
ID: rnd.SessionID("9d8b8801ffa23eb52e08ca7766283799ddfd8dd368208a9b"), ID: rnd.SessionID("9d8b8801ffa23eb52e08ca7766283799ddfd8dd368208a9b"),
RefID: "sessgh6gjuo1", RefID: "sessgh6gjuo1",
SessTimeout: 0, SessTimeout: 0,
SessExpires: UnixTime() + UnixWeek, SessExpires: unix.Time() + unix.Week,
AuthScope: clean.Scope("metrics"), AuthScope: clean.Scope("metrics"),
AuthProvider: authn.ProviderAccessToken.String(), AuthProvider: authn.ProviderAccessToken.String(),
AuthMethod: authn.MethodDefault.String(), AuthMethod: authn.MethodDefault.String(),
@@ -193,7 +194,7 @@ var SessionFixtures = SessionMap{
ID: rnd.SessionID("3f9684f7d3dd3d5b84edd43289c7fb5ca32ee73bd0233237"), ID: rnd.SessionID("3f9684f7d3dd3d5b84edd43289c7fb5ca32ee73bd0233237"),
RefID: "sessyugn54so", RefID: "sessyugn54so",
SessTimeout: 0, SessTimeout: 0,
SessExpires: UnixTime() + UnixWeek, SessExpires: unix.Time() + unix.Week,
AuthScope: clean.Scope("settings"), AuthScope: clean.Scope("settings"),
AuthProvider: authn.ProviderAccessToken.String(), AuthProvider: authn.ProviderAccessToken.String(),
AuthMethod: authn.MethodDefault.String(), AuthMethod: authn.MethodDefault.String(),
@@ -209,7 +210,7 @@ var SessionFixtures = SessionMap{
ID: rnd.SessionID("9d8b8801ffa23eb52e08ca7766283799ddfd8dd368212123"), ID: rnd.SessionID("9d8b8801ffa23eb52e08ca7766283799ddfd8dd368212123"),
RefID: "sessgh6123yt", RefID: "sessgh6123yt",
SessTimeout: 0, SessTimeout: 0,
SessExpires: UnixTime() + UnixWeek, SessExpires: unix.Time() + unix.Week,
AuthScope: clean.Scope("statistics"), AuthScope: clean.Scope("statistics"),
AuthProvider: authn.ProviderClientCredentials.String(), AuthProvider: authn.ProviderClientCredentials.String(),
AuthMethod: authn.MethodOAuth2.String(), AuthMethod: authn.MethodOAuth2.String(),

View File

@@ -12,6 +12,7 @@ import (
"github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/authn" "github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/rnd" "github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/unix"
) )
func TestAuthSession(t *testing.T) { func TestAuthSession(t *testing.T) {
@@ -275,7 +276,7 @@ func TestSessionLogIn(t *testing.T) {
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
t.Run("Admin", func(t *testing.T) { t.Run("Admin", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour*6) m := NewSession(unix.Day, unix.Hour*6)
m.SetClientIP(clientIp) m.SetClientIP(clientIp)
// Create login form. // Create login form.
@@ -295,7 +296,7 @@ func TestSessionLogIn(t *testing.T) {
} }
}) })
t.Run("WrongPassword", func(t *testing.T) { t.Run("WrongPassword", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour*6) m := NewSession(unix.Day, unix.Hour*6)
m.SetClientIP(clientIp) m.SetClientIP(clientIp)
// Create login form. // Create login form.
@@ -315,7 +316,7 @@ func TestSessionLogIn(t *testing.T) {
} }
}) })
t.Run("InvalidUser", func(t *testing.T) { t.Run("InvalidUser", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour*6) m := NewSession(unix.Day, unix.Hour*6)
m.SetClientIP(clientIp) m.SetClientIP(clientIp)
// Create login form. // Create login form.
@@ -335,7 +336,7 @@ func TestSessionLogIn(t *testing.T) {
} }
}) })
t.Run("Unknown user with token", func(t *testing.T) { t.Run("Unknown user with token", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour*6) m := NewSession(unix.Day, unix.Hour*6)
m.SetClientIP(clientIp) m.SetClientIP(clientIp)
// Create login form. // Create login form.
@@ -355,7 +356,7 @@ func TestSessionLogIn(t *testing.T) {
}) })
t.Run("Unknown user with invalid token", func(t *testing.T) { t.Run("Unknown user with invalid token", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour*6) m := NewSession(unix.Day, unix.Hour*6)
m.SetClientIP(clientIp) m.SetClientIP(clientIp)
// Create login form. // Create login form.

View File

@@ -11,11 +11,12 @@ import (
"github.com/photoprism/photoprism/pkg/authn" "github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/header" "github.com/photoprism/photoprism/pkg/header"
"github.com/photoprism/photoprism/pkg/rnd" "github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/unix"
) )
func TestNewSession(t *testing.T) { func TestNewSession(t *testing.T) {
t.Run("NoSessionData", func(t *testing.T) { t.Run("NoSessionData", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour*6) m := NewSession(unix.Day, unix.Hour*6)
assert.True(t, rnd.IsAuthToken(m.AuthToken())) assert.True(t, rnd.IsAuthToken(m.AuthToken()))
assert.True(t, rnd.IsSessionID(m.ID)) assert.True(t, rnd.IsSessionID(m.ID))
@@ -27,7 +28,7 @@ func TestNewSession(t *testing.T) {
assert.Equal(t, 0, len(m.Data().Tokens)) assert.Equal(t, 0, len(m.Data().Tokens))
}) })
t.Run("EmptySessionData", func(t *testing.T) { t.Run("EmptySessionData", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour*6) m := NewSession(unix.Day, unix.Hour*6)
m.SetData(NewSessionData()) m.SetData(NewSessionData())
assert.True(t, rnd.IsAuthToken(m.AuthToken())) assert.True(t, rnd.IsAuthToken(m.AuthToken()))
@@ -42,7 +43,7 @@ func TestNewSession(t *testing.T) {
t.Run("WithSessionData", func(t *testing.T) { t.Run("WithSessionData", func(t *testing.T) {
data := NewSessionData() data := NewSessionData()
data.Tokens = []string{"foo", "bar"} data.Tokens = []string{"foo", "bar"}
m := NewSession(UnixDay, UnixHour*6) m := NewSession(unix.Day, unix.Hour*6)
m.SetData(data) m.SetData(data)
assert.True(t, rnd.IsAuthToken(m.AuthToken())) assert.True(t, rnd.IsAuthToken(m.AuthToken()))
@@ -60,7 +61,7 @@ func TestNewSession(t *testing.T) {
func TestSession_SetData(t *testing.T) { func TestSession_SetData(t *testing.T) {
t.Run("Nil", func(t *testing.T) { t.Run("Nil", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour*6) m := NewSession(unix.Day, unix.Hour*6)
assert.NotNil(t, m) assert.NotNil(t, m)
@@ -74,7 +75,7 @@ func TestSession_SetData(t *testing.T) {
func TestSession_Expires(t *testing.T) { func TestSession_Expires(t *testing.T) {
t.Run("Set expiry date", func(t *testing.T) { t.Run("Set expiry date", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour) m := NewSession(unix.Day, unix.Hour)
initialExpiryDate := m.SessExpires initialExpiryDate := m.SessExpires
m.Expires(time.Date(2035, 01, 15, 12, 30, 0, 0, time.UTC)) m.Expires(time.Date(2035, 01, 15, 12, 30, 0, 0, time.UTC))
finalExpiryDate := m.SessExpires finalExpiryDate := m.SessExpires
@@ -82,7 +83,7 @@ func TestSession_Expires(t *testing.T) {
}) })
t.Run("Try to set zero date", func(t *testing.T) { t.Run("Try to set zero date", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour) m := NewSession(unix.Day, unix.Hour)
initialExpiryDate := m.SessExpires initialExpiryDate := m.SessExpires
m.Expires(time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)) m.Expires(time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC))
finalExpiryDate := m.SessExpires finalExpiryDate := m.SessExpires
@@ -92,7 +93,7 @@ func TestSession_Expires(t *testing.T) {
func TestDeleteExpiredSessions(t *testing.T) { func TestDeleteExpiredSessions(t *testing.T) {
assert.Equal(t, 0, DeleteExpiredSessions()) assert.Equal(t, 0, DeleteExpiredSessions())
m := NewSession(UnixDay, UnixHour) m := NewSession(unix.Day, unix.Hour)
m.Expires(time.Date(2000, 01, 15, 12, 30, 0, 0, time.UTC)) m.Expires(time.Date(2000, 01, 15, 12, 30, 0, 0, time.UTC))
m.Save() m.Save()
assert.Equal(t, 1, DeleteExpiredSessions()) assert.Equal(t, 1, DeleteExpiredSessions())
@@ -161,7 +162,7 @@ func TestFindSessionByRefID(t *testing.T) {
func TestSession_Regenerate(t *testing.T) { func TestSession_Regenerate(t *testing.T) {
t.Run("NewSession", func(t *testing.T) { t.Run("NewSession", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour) m := NewSession(unix.Day, unix.Hour)
initialID := m.ID initialID := m.ID
m.Regenerate() m.Regenerate()
finalID := m.ID finalID := m.ID
@@ -226,8 +227,8 @@ func TestSession_Create(t *testing.T) {
assert.Empty(t, m) assert.Empty(t, m)
s := &Session{ s := &Session{
UserName: "charles", UserName: "charles",
SessExpires: UnixDay * 3, SessExpires: unix.Day * 3,
SessTimeout: UnixTime() + UnixWeek, SessTimeout: unix.Time() + unix.Week,
RefID: "sessxkkcxxxx", RefID: "sessxkkcxxxx",
} }
@@ -252,8 +253,8 @@ func TestSession_Create(t *testing.T) {
s := &Session{ s := &Session{
UserName: "charles", UserName: "charles",
SessExpires: UnixDay * 3, SessExpires: unix.Day * 3,
SessTimeout: UnixTime() + UnixWeek, SessTimeout: unix.Time() + unix.Week,
RefID: "123", RefID: "123",
} }
@@ -274,8 +275,8 @@ func TestSession_Create(t *testing.T) {
s := &Session{ s := &Session{
UserName: "charles", UserName: "charles",
SessExpires: UnixDay * 3, SessExpires: unix.Day * 3,
SessTimeout: UnixTime() + UnixWeek, SessTimeout: unix.Time() + unix.Week,
RefID: "sessxkkcxxxx", RefID: "sessxkkcxxxx",
} }
@@ -292,8 +293,8 @@ func TestSession_Save(t *testing.T) {
assert.Empty(t, m) assert.Empty(t, m)
s := &Session{ s := &Session{
UserName: "chris", UserName: "chris",
SessExpires: UnixDay * 3, SessExpires: unix.Day * 3,
SessTimeout: UnixTime() + UnixWeek, SessTimeout: unix.Time() + unix.Week,
RefID: "sessxkkcxxxy", RefID: "sessxkkcxxxy",
} }
@@ -743,14 +744,14 @@ func TestSession_RedeemToken(t *testing.T) {
func TestSession_TimedOut(t *testing.T) { func TestSession_TimedOut(t *testing.T) {
t.Run("NewSession", func(t *testing.T) { t.Run("NewSession", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour) m := NewSession(unix.Day, unix.Hour)
assert.False(t, m.TimeoutAt().IsZero()) assert.False(t, m.TimeoutAt().IsZero())
assert.Equal(t, m.ExpiresAt(), m.TimeoutAt()) assert.Equal(t, m.ExpiresAt(), m.TimeoutAt())
assert.False(t, m.TimedOut()) assert.False(t, m.TimedOut())
assert.Greater(t, m.ExpiresIn(), int64(0)) assert.Greater(t, m.ExpiresIn(), int64(0))
}) })
t.Run("NoExpiration", func(t *testing.T) { t.Run("NoExpiration", func(t *testing.T) {
m := NewSession(0, UnixHour) m := NewSession(0, unix.Hour)
t.Logf("Timeout: %s, Expiration: %s", m.TimeoutAt().String(), m.ExpiresAt()) t.Logf("Timeout: %s, Expiration: %s", m.TimeoutAt().String(), m.ExpiresAt())
assert.True(t, m.TimeoutAt().IsZero()) assert.True(t, m.TimeoutAt().IsZero())
assert.Equal(t, m.ExpiresAt(), m.TimeoutAt()) assert.Equal(t, m.ExpiresAt(), m.TimeoutAt())
@@ -759,7 +760,7 @@ func TestSession_TimedOut(t *testing.T) {
assert.Equal(t, m.ExpiresIn(), int64(0)) assert.Equal(t, m.ExpiresIn(), int64(0))
}) })
t.Run("NoTimeout", func(t *testing.T) { t.Run("NoTimeout", func(t *testing.T) {
m := NewSession(UnixDay, 0) m := NewSession(unix.Day, 0)
t.Logf("Timeout: %s, Expiration: %s", m.TimeoutAt().String(), m.ExpiresAt()) t.Logf("Timeout: %s, Expiration: %s", m.TimeoutAt().String(), m.ExpiresAt())
assert.False(t, m.TimeoutAt().IsZero()) assert.False(t, m.TimeoutAt().IsZero())
assert.Equal(t, m.ExpiresAt(), m.TimeoutAt()) assert.Equal(t, m.ExpiresAt(), m.TimeoutAt())
@@ -768,19 +769,19 @@ func TestSession_TimedOut(t *testing.T) {
assert.Greater(t, m.ExpiresIn(), int64(0)) assert.Greater(t, m.ExpiresIn(), int64(0))
}) })
t.Run("TimedOut", func(t *testing.T) { t.Run("TimedOut", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour) m := NewSession(unix.Day, unix.Hour)
utc := UnixTime() utc := unix.Time()
m.LastActive = utc - (UnixHour + 1) m.LastActive = utc - (unix.Hour + 1)
assert.False(t, m.TimeoutAt().IsZero()) assert.False(t, m.TimeoutAt().IsZero())
assert.True(t, m.TimedOut()) assert.True(t, m.TimedOut())
}) })
t.Run("NotTimedOut", func(t *testing.T) { t.Run("NotTimedOut", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour) m := NewSession(unix.Day, unix.Hour)
utc := UnixTime() utc := unix.Time()
m.LastActive = utc - (UnixHour - 10) m.LastActive = utc - (unix.Hour - 10)
assert.False(t, m.TimeoutAt().IsZero()) assert.False(t, m.TimeoutAt().IsZero())
assert.False(t, m.TimedOut()) assert.False(t, m.TimedOut())
@@ -789,7 +790,7 @@ func TestSession_TimedOut(t *testing.T) {
func TestSession_Expired(t *testing.T) { func TestSession_Expired(t *testing.T) {
t.Run("NewSession", func(t *testing.T) { t.Run("NewSession", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour) m := NewSession(unix.Day, unix.Hour)
t.Logf("Timeout: %s, Expiration: %s", m.TimeoutAt().String(), m.ExpiresAt()) t.Logf("Timeout: %s, Expiration: %s", m.TimeoutAt().String(), m.ExpiresAt())
assert.False(t, m.ExpiresAt().IsZero()) assert.False(t, m.ExpiresAt().IsZero())
assert.False(t, m.Expired()) assert.False(t, m.Expired())
@@ -813,9 +814,9 @@ func TestSession_Expired(t *testing.T) {
assert.False(t, m.TimedOut()) assert.False(t, m.TimedOut())
}) })
t.Run("Expired", func(t *testing.T) { t.Run("Expired", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour) m := NewSession(unix.Day, unix.Hour)
t.Logf("Timeout: %s, Expiration: %s", m.TimeoutAt().String(), m.ExpiresAt()) t.Logf("Timeout: %s, Expiration: %s", m.TimeoutAt().String(), m.ExpiresAt())
utc := UnixTime() utc := unix.Time()
m.SessExpires = utc - 10 m.SessExpires = utc - 10
@@ -826,8 +827,8 @@ func TestSession_Expired(t *testing.T) {
assert.Equal(t, m.ExpiresAt(), m.TimeoutAt()) assert.Equal(t, m.ExpiresAt(), m.TimeoutAt())
}) })
t.Run("NotExpired", func(t *testing.T) { t.Run("NotExpired", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour) m := NewSession(unix.Day, unix.Hour)
utc := UnixTime() utc := unix.Time()
m.SessExpires = utc + 10 m.SessExpires = utc + 10

View File

@@ -7,34 +7,11 @@ import (
// Day specified as time.Duration to improve readability. // Day specified as time.Duration to improve readability.
const Day = time.Hour * 24 const Day = time.Hour * 24
// UnixMinute is one minute in UnixTime.
const UnixMinute int64 = 60
// UnixHour is one hour in UnixTime.
const UnixHour = UnixMinute * 60
// UnixDay is one day in UnixTime.
const UnixDay = UnixHour * 24
// UnixWeek is one week in UnixTime.
const UnixWeek = UnixDay * 7
// UnixMonth is about one month in UnixTime.
const UnixMonth = UnixDay * 31
// UnixYear is about one year in UnixTime.
const UnixYear = UnixDay * 365
// UTC returns the current Coordinated Universal Time (UTC). // UTC returns the current Coordinated Universal Time (UTC).
func UTC() time.Time { func UTC() time.Time {
return time.Now().UTC() return time.Now().UTC()
} }
// UnixTime returns the current time in seconds since January 1, 1970 UTC.
func UnixTime() int64 {
return UTC().Unix()
}
// TimeStamp returns the current timestamp in UTC rounded to seconds. // TimeStamp returns the current timestamp in UTC rounded to seconds.
func TimeStamp() time.Time { func TimeStamp() time.Time {
return UTC().Truncate(time.Second) return UTC().Truncate(time.Second)

View File

@@ -3,15 +3,19 @@ package form
import ( import (
"github.com/urfave/cli" "github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/pkg/authn" "github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/rnd" "github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/unix"
) )
// Client represents client application settings. // Client represents client application settings.
type Client struct { type Client struct {
UserUID string `json:"UserUID,omitempty" yaml:"UserUID,omitempty"` UserUID string `json:"UserUID,omitempty" yaml:"UserUID,omitempty"`
UserName string `gorm:"size:64;index;" json:"UserName" yaml:"UserName,omitempty"` UserName string `gorm:"size:64;index;" json:"UserName" yaml:"UserName,omitempty"`
ClientID string `json:"ClientID,omitempty" yaml:"ClientID,omitempty"`
ClientSecret string `json:"ClientSecret,omitempty" yaml:"ClientSecret,omitempty"`
ClientName string `json:"ClientName,omitempty" yaml:"ClientName,omitempty"` ClientName string `json:"ClientName,omitempty" yaml:"ClientName,omitempty"`
ClientRole string `json:"ClientRole,omitempty" yaml:"ClientRole,omitempty"` ClientRole string `json:"ClientRole,omitempty" yaml:"ClientRole,omitempty"`
AuthProvider string `json:"AuthProvider,omitempty" yaml:"AuthProvider,omitempty"` AuthProvider string `json:"AuthProvider,omitempty" yaml:"AuthProvider,omitempty"`
@@ -27,7 +31,10 @@ func NewClient() Client {
return Client{ return Client{
UserUID: "", UserUID: "",
UserName: "", UserName: "",
ClientID: "",
ClientSecret: "",
ClientName: "", ClientName: "",
ClientRole: acl.RoleClient.String(),
AuthProvider: authn.ProviderClientCredentials.String(), AuthProvider: authn.ProviderClientCredentials.String(),
AuthMethod: authn.MethodOAuth2.String(), AuthMethod: authn.MethodOAuth2.String(),
AuthScope: "", AuthScope: "",
@@ -37,29 +44,124 @@ func NewClient() Client {
} }
} }
// NewClientFromCli creates a new form with values from a CLI context. // AddClientFromCli creates a new form for adding a client with values from the specified CLI context.
func NewClientFromCli(ctx *cli.Context) Client { func AddClientFromCli(ctx *cli.Context) Client {
f := NewClient() f := NewClient()
f.ClientName = clean.Name(ctx.String("name"))
f.ClientRole = clean.Name(ctx.String("role"))
f.AuthProvider = authn.Provider(ctx.String("provider")).String()
f.AuthMethod = authn.Method(ctx.String("method")).String()
f.AuthScope = clean.Scope(ctx.String("scope"))
if authn.MethodOAuth2.NotEqual(f.AuthMethod) {
f.AuthScope = "webdav"
}
if user := clean.Username(ctx.Args().First()); rnd.IsUID(user, 'u') { if user := clean.Username(ctx.Args().First()); rnd.IsUID(user, 'u') {
f.UserUID = user f.UserUID = user
} else if user != "" { } else if user != "" {
f.UserName = user f.UserName = user
} }
if ctx.IsSet("id") {
f.ClientID = ctx.String("id")
}
if ctx.IsSet("secret") {
f.ClientSecret = ctx.String("secret")
}
if ctx.IsSet("name") {
f.ClientName = clean.Name(ctx.String("name"))
}
if f.ClientName == "" {
f.ClientName = rnd.Name()
}
f.ClientRole = clean.Name(ctx.String("role"))
if f.ClientRole == "" {
f.ClientRole = acl.RoleClient.String()
}
f.AuthProvider = authn.Provider(ctx.String("provider")).String()
if f.AuthProvider == "" {
f.AuthProvider = authn.ProviderClientCredentials.String()
}
f.AuthMethod = authn.Method(ctx.String("method")).String()
if f.AuthMethod == "" {
f.AuthMethod = authn.MethodOAuth2.String()
}
f.AuthScope = clean.Scope(ctx.String("scope"))
if f.AuthScope == "" {
f.AuthScope = "*"
}
return f return f
} }
// ModClientFromCli creates a new form for modifying a client with values from the specified CLI context.
func ModClientFromCli(ctx *cli.Context) Client {
f := Client{}
f.ClientID = clean.UID(ctx.Args().First())
if ctx.IsSet("secret") {
f.ClientSecret = ctx.String("secret")
}
if ctx.IsSet("name") {
f.ClientName = clean.Name(ctx.String("name"))
}
if ctx.IsSet("role") {
f.ClientRole = clean.Name(ctx.String("role"))
}
if ctx.IsSet("provider") {
f.AuthProvider = authn.Provider(ctx.String("provider")).String()
}
if ctx.IsSet("method") {
f.AuthMethod = authn.Method(ctx.String("method")).String()
}
if ctx.IsSet("scope") {
f.AuthScope = clean.Scope(ctx.String("scope"))
}
if ctx.IsSet("expires") {
f.AuthExpires = ctx.Int64("expires")
}
if ctx.IsSet("tokens") {
f.AuthTokens = ctx.Int64("tokens")
}
if ctx.Bool("enable") {
f.AuthEnabled = true
} else if ctx.Bool("disable") {
f.AuthEnabled = false
}
return f
}
// ID returns the client id, if any.
func (f *Client) ID() string {
if !rnd.IsUID(f.ClientID, 'c') {
return ""
}
return f.ClientID
}
// Secret returns the client secret, if any.
func (f *Client) Secret() string {
if !rnd.IsClientSecret(f.ClientSecret) {
return ""
}
return f.ClientSecret
}
// Name returns the sanitized client name. // Name returns the sanitized client name.
func (f *Client) Name() string { func (f *Client) Name() string {
return clean.Name(f.ClientName) return clean.Name(f.ClientName)
@@ -84,3 +186,29 @@ func (f *Client) Method() authn.MethodType {
func (f Client) Scope() string { func (f Client) Scope() string {
return clean.Scope(f.AuthScope) return clean.Scope(f.AuthScope)
} }
// Expires returns the access token expiry time in seconds or 0 if not specified.
func (f Client) Expires() int64 {
if f.AuthExpires > unix.Month {
return unix.Month
} else if f.AuthExpires > 0 {
return f.AuthExpires
} else if f.AuthExpires < 0 {
return unix.Hour
}
return 0
}
// Tokens returns the access token limit or 0 if not specified.
func (f Client) Tokens() int64 {
if f.AuthTokens > 2147483647 {
return 2147483647
} else if f.AuthTokens > 0 {
return f.AuthTokens
} else if f.AuthTokens < 0 {
return -1
}
return 0
}

View File

@@ -20,23 +20,74 @@ func TestNewClient(t *testing.T) {
}) })
} }
func TestNewClientFromCli(t *testing.T) { func TestAddClientFromCli(t *testing.T) {
// Specify command flags.
flags := flag.NewFlagSet("test", 0)
flags.String("name", "(default)", "Usage")
flags.String("scope", "(default)", "Usage")
flags.String("provider", "(default)", "Usage")
flags.String("method", "(default)", "Usage")
t.Run("Success", func(t *testing.T) { t.Run("Success", func(t *testing.T) {
globalSet := flag.NewFlagSet("test", 0) // Create new context with flags.
globalSet.String("name", "Test", "") ctx := cli.NewContext(cli.NewApp(), flags, nil)
globalSet.String("scope", "*", "")
globalSet.String("provider", "client_credentials", "")
globalSet.String("method", "totp", "")
app := cli.NewApp() // Set flag values.
app.Version = "0.0.0" assert.NoError(t, ctx.Set("name", "Test"))
assert.NoError(t, ctx.Set("scope", "*"))
assert.NoError(t, ctx.Set("provider", "client_credentials"))
assert.NoError(t, ctx.Set("method", "totp"))
c := cli.NewContext(app, globalSet, nil) t.Logf("ARGS: %#v", ctx.Args())
client := NewClientFromCli(c) // Check flag values.
assert.True(t, ctx.IsSet("name"))
assert.Equal(t, "Test", ctx.String("name"))
assert.True(t, ctx.IsSet("provider"))
assert.Equal(t, "client_credentials", ctx.String("provider"))
// Set form values.
client := AddClientFromCli(ctx)
// Check form values.
assert.Equal(t, authn.ProviderClientCredentials, client.Provider()) assert.Equal(t, authn.ProviderClientCredentials, client.Provider())
assert.Equal(t, authn.MethodTOTP, client.Method()) assert.Equal(t, authn.MethodTOTP, client.Method())
assert.Equal(t, "webdav", client.Scope()) assert.Equal(t, "*", client.Scope())
assert.Equal(t, "Test", client.Name())
})
}
func TestModClientFromCli(t *testing.T) {
// Specify command flags.
flags := flag.NewFlagSet("test", 0)
flags.String("name", "(default)", "Usage")
flags.String("scope", "(default)", "Usage")
flags.String("provider", "(default)", "Usage")
flags.String("method", "(default)", "Usage")
t.Run("Success", func(t *testing.T) {
// Create new context with flags.
ctx := cli.NewContext(cli.NewApp(), flags, nil)
// Set flag values.
assert.NoError(t, ctx.Set("name", "Test"))
assert.NoError(t, ctx.Set("scope", "*"))
assert.NoError(t, ctx.Set("provider", "client_credentials"))
assert.NoError(t, ctx.Set("method", "totp"))
// Check flag values.
assert.True(t, ctx.IsSet("name"))
assert.Equal(t, "Test", ctx.String("name"))
assert.True(t, ctx.IsSet("provider"))
assert.Equal(t, "client_credentials", ctx.String("provider"))
// Set form values.
client := ModClientFromCli(ctx)
// Check form values.
assert.Equal(t, authn.ProviderClientCredentials, client.Provider())
assert.Equal(t, authn.MethodTOTP, client.Method())
assert.Equal(t, "*", client.Scope())
assert.Equal(t, "Test", client.Name()) assert.Equal(t, "Test", client.Name())
}) })
} }

View File

@@ -6,6 +6,7 @@ import (
"github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/rnd" "github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/unix"
) )
// Session finds an existing session by its id. // Session finds an existing session by its id.
@@ -31,7 +32,7 @@ func Sessions(limit, offset int, sortOrder, search string) (result entity.Sessio
search = strings.TrimSpace(search) search = strings.TrimSpace(search)
if search == "expired" { if search == "expired" {
stmt = stmt.Where("sess_expires > 0 AND sess_expires < ?", entity.UnixTime()) stmt = stmt.Where("sess_expires > 0 AND sess_expires < ?", unix.Time())
} else if rnd.IsSessionID(search) { } else if rnd.IsSessionID(search) {
stmt = stmt.Where("id = ?", search) stmt = stmt.Where("id = ?", search)
} else if rnd.IsAuthToken(search) { } else if rnd.IsAuthToken(search) {

View File

@@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/unix"
) )
// Save updates the client session or creates a new one if needed. // Save updates the client session or creates a new one if needed.
@@ -15,7 +16,7 @@ func (s *Session) Save(m *entity.Session) (*entity.Session, error) {
} }
// Update last active timestamp. // Update last active timestamp.
m.LastActive = entity.UnixTime() m.LastActive = unix.Time()
// Save session. // Save session.
err := m.Save() err := m.Save()

View File

@@ -21,6 +21,11 @@ const (
MethodUnknown MethodType = "" MethodUnknown MethodType = ""
) )
// IsUnknown checks if the method is unknown.
func (t MethodType) IsUnknown() bool {
return t == ""
}
// IsDefault checks if this is the default method. // IsDefault checks if this is the default method.
func (t MethodType) IsDefault() bool { func (t MethodType) IsDefault() bool {
return t.String() == MethodDefault.String() return t.String() == MethodDefault.String()

View File

@@ -43,6 +43,11 @@ var ClientProviders = list.List{
string(ProviderAccessToken), string(ProviderAccessToken),
} }
// IsUnknown checks if the provider is unknown.
func (t ProviderType) IsUnknown() bool {
return t == ""
}
// IsRemote checks if the provider is external. // IsRemote checks if the provider is external.
func (t ProviderType) IsRemote() bool { func (t ProviderType) IsRemote() bool {
return list.Contains(RemoteProviders, string(t)) return list.Contains(RemoteProviders, string(t))

View File

@@ -4,6 +4,8 @@ import (
"strconv" "strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/pkg/unix"
) )
const ( const (
@@ -48,8 +50,8 @@ var (
func CacheControlMaxAge(maxAge int, public bool) string { func CacheControlMaxAge(maxAge int, public bool) string {
if maxAge < 0 { if maxAge < 0 {
return CacheControlNoCache return CacheControlNoCache
} else if maxAge > 31536000 { } else if maxAge > unix.YearInt {
maxAge = 31536000 maxAge = unix.YearInt
} }
switch { switch {

View File

@@ -6,8 +6,9 @@ import (
"testing" "testing"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/pkg/unix"
) )
func TestCacheControlMaxAge(t *testing.T) { func TestCacheControlMaxAge(t *testing.T) {
@@ -15,27 +16,27 @@ func TestCacheControlMaxAge(t *testing.T) {
assert.Equal(t, CacheControlPrivateDefault, CacheControlMaxAge(0, false)) assert.Equal(t, CacheControlPrivateDefault, CacheControlMaxAge(0, false))
assert.Equal(t, "no-cache", CacheControlMaxAge(-1, false)) assert.Equal(t, "no-cache", CacheControlMaxAge(-1, false))
assert.Equal(t, "private, max-age=1", CacheControlMaxAge(1, false)) assert.Equal(t, "private, max-age=1", CacheControlMaxAge(1, false))
assert.Equal(t, "private, max-age=31536000", CacheControlMaxAge(31536000, false)) assert.Equal(t, "private, max-age=31536000", CacheControlMaxAge(unix.YearInt, false))
assert.Equal(t, "private, max-age=31536000", CacheControlMaxAge(1231536000, false)) assert.Equal(t, "private, max-age=31536000", CacheControlMaxAge(1231536000, false))
}) })
t.Run("Public", func(t *testing.T) { t.Run("Public", func(t *testing.T) {
assert.Equal(t, CacheControlPublicDefault, CacheControlMaxAge(0, true)) assert.Equal(t, CacheControlPublicDefault, CacheControlMaxAge(0, true))
assert.Equal(t, "no-cache", CacheControlMaxAge(-1, true)) assert.Equal(t, "no-cache", CacheControlMaxAge(-1, true))
assert.Equal(t, "public, max-age=1", CacheControlMaxAge(1, true)) assert.Equal(t, "public, max-age=1", CacheControlMaxAge(1, true))
assert.Equal(t, "public, max-age=31536000", CacheControlMaxAge(31536000, true)) assert.Equal(t, "public, max-age=31536000", CacheControlMaxAge(unix.YearInt, true))
assert.Equal(t, "public, max-age=31536000", CacheControlMaxAge(1231536000, true)) assert.Equal(t, "public, max-age=31536000", CacheControlMaxAge(1231536000, true))
}) })
} }
func BenchmarkTestCacheControlMaxAge(b *testing.B) { func BenchmarkTestCacheControlMaxAge(b *testing.B) {
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
_ = CacheControlMaxAge(31536000, false) _ = CacheControlMaxAge(unix.YearInt, false)
} }
} }
func BenchmarkTestCacheControlMaxAgeImmutable(b *testing.B) { func BenchmarkTestCacheControlMaxAgeImmutable(b *testing.B) {
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
_ = CacheControlMaxAge(31536000, false) + ", " + CacheControlImmutable _ = CacheControlMaxAge(unix.YearInt, false) + ", " + CacheControlImmutable
} }
} }
@@ -48,7 +49,7 @@ func TestSetCacheControl(t *testing.T) {
Header: make(http.Header), Header: make(http.Header),
} }
SetCacheControl(c, 31536000, false) SetCacheControl(c, unix.YearInt, false)
assert.Equal(t, "private, max-age=31536000", c.Writer.Header().Get(CacheControl)) assert.Equal(t, "private, max-age=31536000", c.Writer.Header().Get(CacheControl))
}) })
t.Run("Public", func(t *testing.T) { t.Run("Public", func(t *testing.T) {
@@ -59,7 +60,7 @@ func TestSetCacheControl(t *testing.T) {
Header: make(http.Header), Header: make(http.Header),
} }
SetCacheControl(c, 31536000, true) SetCacheControl(c, unix.YearInt, true)
assert.Equal(t, "public, max-age=31536000", c.Writer.Header().Get(CacheControl)) assert.Equal(t, "public, max-age=31536000", c.Writer.Header().Get(CacheControl))
}) })
t.Run("NoCache", func(t *testing.T) { t.Run("NoCache", func(t *testing.T) {
@@ -84,7 +85,7 @@ func TestSetCacheControlImmutable(t *testing.T) {
Header: make(http.Header), Header: make(http.Header),
} }
SetCacheControlImmutable(c, 31536000, false) SetCacheControlImmutable(c, unix.YearInt, false)
assert.Equal(t, "private, max-age=31536000, immutable", c.Writer.Header().Get(CacheControl)) assert.Equal(t, "private, max-age=31536000, immutable", c.Writer.Header().Get(CacheControl))
}) })
t.Run("Public", func(t *testing.T) { t.Run("Public", func(t *testing.T) {
@@ -95,7 +96,7 @@ func TestSetCacheControlImmutable(t *testing.T) {
Header: make(http.Header), Header: make(http.Header),
} }
SetCacheControlImmutable(c, 31536000, true) SetCacheControlImmutable(c, unix.YearInt, true)
assert.Equal(t, "public, max-age=31536000, immutable", c.Writer.Header().Get(CacheControl)) assert.Equal(t, "public, max-age=31536000, immutable", c.Writer.Header().Get(CacheControl))
}) })
t.Run("PublicDefault", func(t *testing.T) { t.Run("PublicDefault", func(t *testing.T) {

View File

@@ -1,5 +1,5 @@
/* /*
Package sev provides event importance levels and parsers. Package level provides constants and abstractions for log levels and severities.
Copyright (c) 2018 - 2024 PhotoPrism UG. All rights reserved. Copyright (c) 2018 - 2024 PhotoPrism UG. All rights reserved.
@@ -22,16 +22,4 @@ want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide: Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/> <https://docs.photoprism.app/developer-guide/>
*/ */
package sev package level
// Level represents the severity of an event.
type Level uint8
// String returns the severity level as a string, e.g. Alert becomes "alert".
func (level Level) String() string {
if b, err := level.MarshalText(); err == nil {
return string(b)
} else {
return "unknown"
}
}

View File

@@ -1,11 +1,12 @@
package sev package level
import ( import (
"fmt" "fmt"
) )
// Severity levels.
const ( const (
Emergency Level = iota Emergency Severity = iota
Alert Alert
Critical Critical
Error Error
@@ -15,7 +16,8 @@ const (
Debug Debug
) )
var Levels = []Level{ // Levels contains the valid severity levels.
var Levels = []Severity{
Emergency, Emergency,
Alert, Alert,
Critical, Critical,
@@ -27,7 +29,7 @@ var Levels = []Level{
} }
// UnmarshalText implements encoding.TextUnmarshaler. // UnmarshalText implements encoding.TextUnmarshaler.
func (level *Level) UnmarshalText(text []byte) error { func (level *Severity) UnmarshalText(text []byte) error {
l, err := Parse(string(text)) l, err := Parse(string(text))
if err != nil { if err != nil {
return err return err
@@ -38,7 +40,8 @@ func (level *Level) UnmarshalText(text []byte) error {
return nil return nil
} }
func (level Level) MarshalText() ([]byte, error) { // MarshalText implements encoding.TextMarshaler.
func (level Severity) MarshalText() ([]byte, error) {
switch level { switch level {
case Debug: case Debug:
return []byte("debug"), nil return []byte("debug"), nil
@@ -61,7 +64,8 @@ func (level Level) MarshalText() ([]byte, error) {
return nil, fmt.Errorf("not a valid severity level %d", level) return nil, fmt.Errorf("not a valid severity level %d", level)
} }
func (level Level) Status() string { // Status returns the severity level as an info string for reports.
func (level Severity) Status() string {
switch level { switch level {
case Warning: case Warning:
return "warning" return "warning"

View File

@@ -1,10 +1,10 @@
package sev package level
import "github.com/sirupsen/logrus" import "github.com/sirupsen/logrus"
// LogLevel takes a logrus log level and returns the severity. // Logrus takes a logrus.Level and returns the corresponding Severity.
func LogLevel(lvl logrus.Level) Level { func Logrus(level logrus.Level) Severity {
switch lvl { switch level {
case logrus.PanicLevel: case logrus.PanicLevel:
return Alert return Alert
case logrus.FatalLevel: case logrus.FatalLevel:

View File

@@ -1,13 +1,13 @@
package sev package level
import ( import (
"fmt" "fmt"
"strings" "strings"
) )
// Parse takes a string level and returns the severity constant. // Parse takes a string and returns the corresponding severity, if any.
func Parse(lvl string) (Level, error) { func Parse(level string) (Severity, error) {
switch strings.ToLower(lvl) { switch strings.ToLower(level) {
case "emergency", "emerg", "panic": case "emergency", "emerg", "panic":
return Emergency, nil return Emergency, nil
case "fatal", "alert": case "fatal", "alert":
@@ -26,6 +26,6 @@ func Parse(lvl string) (Level, error) {
return Debug, nil return Debug, nil
} }
var l Level var l Severity
return l, fmt.Errorf("not a valid Level: %q", lvl) return l, fmt.Errorf("not a valid severity level: %q", level)
} }

13
pkg/level/severity.go Normal file
View File

@@ -0,0 +1,13 @@
package level
// Severity represents the severity level of an event.
type Severity uint8
// String returns the severity level as a string, e.g. Alert becomes "alert".
func (level Severity) String() string {
if b, err := level.MarshalText(); err == nil {
return string(b)
} else {
return "unknown"
}
}

View File

@@ -1,4 +1,4 @@
package sev package level
import ( import (
"bytes" "bytes"
@@ -10,7 +10,7 @@ import (
func TestLevelJsonEncoding(t *testing.T) { func TestLevelJsonEncoding(t *testing.T) {
type X struct { type X struct {
Level Level Level Severity
} }
var x X var x X
@@ -24,7 +24,7 @@ func TestLevelJsonEncoding(t *testing.T) {
} }
func TestLevelUnmarshalText(t *testing.T) { func TestLevelUnmarshalText(t *testing.T) {
var u Level var u Severity
for _, level := range Levels { for _, level := range Levels {
t.Run(level.String(), func(t *testing.T) { t.Run(level.String(), func(t *testing.T) {
assert.NoError(t, u.UnmarshalText([]byte(level.String()))) assert.NoError(t, u.UnmarshalText([]byte(level.String())))
@@ -50,7 +50,7 @@ func TestLevelMarshalText(t *testing.T) {
for idx, val := range Levels { for idx, val := range Levels {
level := val level := val
t.Run(level.String(), func(t *testing.T) { t.Run(level.String(), func(t *testing.T) {
var cmp Level var cmp Severity
b, err := level.MarshalText() b, err := level.MarshalText()
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, levelStrings[idx], string(b)) assert.Equal(t, levelStrings[idx], string(b))

View File

@@ -29,7 +29,7 @@ func AuthToken() string {
return fmt.Sprintf("%x", b) return fmt.Sprintf("%x", b)
} }
// IsAuthToken checks if the string might be a valid auth token. // IsAuthToken checks if the string represents a valid auth token.
func IsAuthToken(s string) bool { func IsAuthToken(s string) bool {
if l := len(s); l == AuthTokenLength { if l := len(s); l == AuthTokenLength {
return IsHex(s) return IsHex(s)
@@ -61,7 +61,7 @@ func AppPassword() string {
return string(b) return string(b)
} }
// IsAppPassword checks if the string might be a valid app password. // IsAppPassword checks if the string represents a valid app password.
func IsAppPassword(s string, verifyChecksum bool) bool { func IsAppPassword(s string, verifyChecksum bool) bool {
// Verify token length. // Verify token length.
if len(s) != AppPasswordLength { if len(s) != AppPasswordLength {
@@ -89,7 +89,7 @@ func IsAppPassword(s string, verifyChecksum bool) bool {
return s[AppPasswordLength-1] == checksum.Char([]byte(s[:AppPasswordLength-1])) return s[AppPasswordLength-1] == checksum.Char([]byte(s[:AppPasswordLength-1]))
} }
// IsAuthAny checks if the string might be a valid auth token or app password. // IsAuthAny checks if the string represents a valid auth token or app password.
func IsAuthAny(s string) bool { func IsAuthAny(s string) bool {
// Check if string might be a regular auth token. // Check if string might be a regular auth token.
if IsAuthToken(s) { if IsAuthToken(s) {
@@ -109,7 +109,7 @@ func SessionID(token string) string {
return Sha256([]byte(token)) return Sha256([]byte(token))
} }
// IsSessionID checks if the string is a session id string. // IsSessionID checks if the string represents a valid session id.
func IsSessionID(id string) bool { func IsSessionID(id string) bool {
if len(id) != SessionIdLength { if len(id) != SessionIdLength {
return false return false

19
pkg/rnd/client.go Normal file
View File

@@ -0,0 +1,19 @@
package rnd
const (
ClientSecretLength = 32
)
// ClientSecret generates a random client secret containing 32 upper and lower case letters as well as numbers.
func ClientSecret() string {
return Base62(ClientSecretLength)
}
// IsClientSecret checks if the string represents a valid client secret.
func IsClientSecret(s string) bool {
if l := len(s); l == ClientSecretLength {
return IsAlnum(s)
}
return false
}

48
pkg/rnd/client_test.go Normal file
View File

@@ -0,0 +1,48 @@
package rnd
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestClientSecret(t *testing.T) {
result := ClientSecret()
assert.Equal(t, ClientSecretLength, len(result))
assert.NotEqual(t, AuthTokenLength, len(result))
assert.True(t, IsClientSecret(result))
assert.False(t, IsAuthToken(result))
assert.False(t, IsHex(result))
for n := 0; n < 10; n++ {
s := ClientSecret()
t.Logf("ClientSecret %d: %s", n, s)
assert.True(t, IsClientSecret(s))
}
}
func BenchmarkClientSecret(b *testing.B) {
for n := 0; n < b.N; n++ {
ClientSecret()
}
}
func TestIsClientSecret(t *testing.T) {
assert.True(t, IsClientSecret(ClientSecret()))
assert.True(t, IsClientSecret("69be27ac5ca305b394046a83f6fda181"))
assert.False(t, IsClientSecret("MPkOqm-RtKGOi-ctIvXm-Qv3XhN"))
assert.False(t, IsClientSecret("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac2"))
assert.False(t, IsClientSecret(AuthToken()))
assert.False(t, IsClientSecret(AuthToken()))
assert.False(t, IsClientSecret(SessionID(AuthToken())))
assert.False(t, IsClientSecret(SessionID(AuthToken())))
assert.False(t, IsClientSecret("55785BAC-9H4B-4747-B090-EE123FFEE437"))
assert.True(t, IsClientSecret("4B1FEF2D1CF4A5BE38B263E0637EDEAD"))
assert.False(t, IsClientSecret(""))
}
func BenchmarkIsClientSecret(b *testing.B) {
for n := 0; n < b.N; n++ {
IsClientSecret("69be27ac5ca305b394046a83f6fda181")
}
}

22
pkg/unix/const.go Normal file
View File

@@ -0,0 +1,22 @@
package unix
// Minute is one minute in seconds.
const Minute int64 = 60
// Hour is one hour in seconds.
const Hour = Minute * 60
// Day is one day in seconds.
const Day = Hour * 24
// Week is one week in seconds.
const Week = Day * 7
// Month is about one month in seconds.
const Month = Day * 31
// Year is 365 days in seconds.
const Year = Day * 365
// YearInt is Year specified as integer.
const YearInt = int(Year)

8
pkg/unix/time.go Normal file
View File

@@ -0,0 +1,8 @@
package unix
import "time"
// Time returns the current time in seconds since January 1, 1970 UTC.
func Time() int64 {
return time.Now().UTC().Unix()
}

16
pkg/unix/time_test.go Normal file
View File

@@ -0,0 +1,16 @@
package unix
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestTime(t *testing.T) {
result := Time()
assert.Greater(t, result, int64(1706521797))
assert.GreaterOrEqual(t, result, time.Now().UTC().Unix())
assert.LessOrEqual(t, result, time.Now().UTC().Unix()+2)
}

25
pkg/unix/unix.go Normal file
View File

@@ -0,0 +1,25 @@
/*
Package unix provides constants and functions for Unix timestamps.
Copyright (c) 2018 - 2024 PhotoPrism UG. All rights reserved.
This program is free software: you can redistribute it and/or modify
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
<https://docs.photoprism.app/license/agpl>
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
The AGPL is supplemented by our Trademark and Brand Guidelines,
which describe how our Brand Assets may be used:
<https://www.photoprism.app/trademark>
Feel free to send an email to hello@photoprism.app if you have questions,
want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/>
*/
package unix