From 305e7bac688d26ee08aaa7eb2e60a72e04df9331 Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Mon, 29 Jan 2024 13:54:50 +0100 Subject: [PATCH] OAuth2: Refactor "client add" and "client mod" CLI commands #808 #3943 Signed-off-by: Michael Mayer --- internal/commands/auth_add.go | 3 +- internal/commands/clients.go | 32 +++- internal/commands/clients_add.go | 49 +++--- internal/commands/clients_list.go | 11 +- internal/commands/clients_list_test.go | 2 +- internal/commands/clients_mod.go | 95 +++++------- internal/entity/auth_client.go | 161 ++++++++++++++------ internal/entity/auth_client_add.go | 6 + internal/entity/auth_client_fixtures.go | 11 +- internal/entity/auth_client_test.go | 4 +- internal/entity/auth_session.go | 7 +- internal/entity/auth_session_cache.go | 3 +- internal/entity/auth_session_client_test.go | 12 +- internal/entity/auth_session_fixtures.go | 39 ++--- internal/entity/auth_session_login_test.go | 11 +- internal/entity/auth_session_test.go | 61 ++++---- internal/entity/entity_time.go | 23 --- internal/form/client.go | 152 ++++++++++++++++-- internal/form/client_test.go | 73 +++++++-- internal/query/sessions.go | 3 +- internal/session/session_save.go | 3 +- pkg/authn/methods.go | 5 + pkg/authn/providers.go | 5 + pkg/header/cache.go | 6 +- pkg/header/cache_test.go | 19 +-- pkg/{sev/severity.go => level/level.go} | 16 +- pkg/{sev => level}/levels.go | 16 +- pkg/{sev => level}/logrus.go | 8 +- pkg/{sev => level}/parse.go | 12 +- pkg/level/severity.go | 13 ++ pkg/{sev => level}/severity_test.go | 8 +- pkg/rnd/auth.go | 8 +- pkg/rnd/client.go | 19 +++ pkg/rnd/client_test.go | 48 ++++++ pkg/unix/const.go | 22 +++ pkg/unix/time.go | 8 + pkg/unix/time_test.go | 16 ++ pkg/unix/unix.go | 25 +++ 38 files changed, 714 insertions(+), 301 deletions(-) rename pkg/{sev/severity.go => level/level.go} (71%) rename pkg/{sev => level}/levels.go (70%) rename pkg/{sev => level}/logrus.go (65%) rename pkg/{sev => level}/parse.go (63%) create mode 100644 pkg/level/severity.go rename pkg/{sev => level}/severity_test.go (94%) create mode 100644 pkg/rnd/client.go create mode 100644 pkg/rnd/client_test.go create mode 100644 pkg/unix/const.go create mode 100644 pkg/unix/time.go create mode 100644 pkg/unix/time_test.go create mode 100644 pkg/unix/unix.go diff --git a/internal/commands/auth_add.go b/internal/commands/auth_add.go index a8ee550f2..7cf6c7a4c 100644 --- a/internal/commands/auth_add.go +++ b/internal/commands/auth_add.go @@ -11,6 +11,7 @@ import ( "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/report" "github.com/photoprism/photoprism/pkg/rnd" + "github.com/photoprism/photoprism/pkg/unix" ) // AuthAddFlags specifies the "photoprism auth add" command flags. @@ -26,7 +27,7 @@ var AuthAddFlags = []cli.Flag{ cli.Int64Flag{ Name: "expires, e", Usage: "authentication `LIFETIME` in seconds, after which access expires (-1 to disable the limit)", - Value: entity.UnixYear, + Value: unix.Year, }, } diff --git a/internal/commands/clients.go b/internal/commands/clients.go index e91febb3a..6aa52504c 100644 --- a/internal/commands/clients.go +++ b/internal/commands/clients.go @@ -4,22 +4,25 @@ import ( "github.com/urfave/cli" "github.com/photoprism/photoprism/internal/acl" - "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/pkg/authn" + "github.com/photoprism/photoprism/pkg/unix" ) // Usage hints for the client management subcommands. const ( + ClientIdUsage = "static client `UID` for test purposes" + ClientSecretUsage = "static client `SECRET` for test purposes" ClientNameUsage = "`CLIENT` name to help identify the application" ClientRoleUsage = "client authorization `ROLE`" ClientAuthScope = "client authorization `SCOPES` e.g. \"metrics\" or \"photos albums\" (\"*\" to allow all)" ClientAuthProvider = "client authentication `PROVIDER`" ClientAuthMethod = "client authentication `METHOD`" - ClientAuthExpires = "authentication `LIFETIME` in seconds, after which a new access token must be requested (-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" + 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)" + ClientRegenerateSecret = "set a new randomly generated client secret" ClientEnable = "enable client authentication if disabled" 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. @@ -39,6 +42,11 @@ var ClientsCommands = cli.Command{ // ClientAddFlags specifies the "photoprism client add" command flags. var ClientAddFlags = []cli.Flag{ + cli.StringFlag{ + Name: "id", + Usage: ClientIdUsage, + Hidden: true, + }, cli.StringFlag{ Name: "name, n", Usage: ClientNameUsage, @@ -67,13 +75,18 @@ var ClientAddFlags = []cli.Flag{ cli.Int64Flag{ Name: "expires, e", Usage: ClientAuthExpires, - Value: entity.UnixDay, + Value: unix.Day, }, cli.Int64Flag{ Name: "tokens, t", Usage: ClientAuthTokens, Value: 10, }, + cli.StringFlag{ + Name: "secret", + Usage: ClientSecretUsage, + Hidden: true, + }, } // ClientModFlags specifies the "photoprism client mod" command flags. @@ -106,13 +119,20 @@ var ClientModFlags = []cli.Flag{ cli.Int64Flag{ Name: "expires, e", Usage: ClientAuthExpires, + Value: unix.Day, }, cli.Int64Flag{ Name: "tokens, t", Usage: ClientAuthTokens, + Value: 10, + }, + cli.StringFlag{ + Name: "secret", + Usage: ClientSecretUsage, + Hidden: true, }, cli.BoolFlag{ - Name: "regenerate-secret, r", + Name: "regenerate", Usage: ClientRegenerateSecret, }, cli.BoolFlag{ diff --git a/internal/commands/clients_add.go b/internal/commands/clients_add.go index 89233d493..abd1865da 100644 --- a/internal/commands/clients_add.go +++ b/internal/commands/clients_add.go @@ -2,7 +2,6 @@ package commands import ( "fmt" - "time" "github.com/manifoldco/promptui" "github.com/urfave/cli" @@ -20,7 +19,7 @@ import ( var ClientsAddCommand = cli.Command{ Name: "add", 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]", Flags: ClientAddFlags, Action: clientsAddAction, @@ -31,7 +30,7 @@ func clientsAddAction(ctx *cli.Context) error { return CallWithDependencies(ctx, func(conf *config.Config) error { conf.MigrateDb(false, nil) - frm := form.NewClientFromCli(ctx) + frm := form.AddClientFromCli(ctx) interactive := true @@ -55,11 +54,6 @@ func clientsAddAction(ctx *cli.Context) error { 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 == "" { prompt := promptui.Prompt{ Label: "Authorization Scope", @@ -82,23 +76,26 @@ func clientsAddAction(ctx *cli.Context) error { client, addErr := entity.AddClient(frm) if addErr != nil { - return fmt.Errorf("failed to add client: %s", addErr) + return addErr } else { log.Infof("successfully registered new client %s", clean.LogQuote(client.ClientName)) // 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) var authExpires string + if client.AuthExpires > 0 { authExpires = client.Expires().String() - } else { - authExpires = report.Never } 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{ @@ -118,16 +115,28 @@ func clientsAddAction(ctx *cli.Context) error { } } - if secret, err := client.NewSecret(); err != nil { - // Failed to create client secret. - return fmt.Errorf("failed to create client secret: %s", err) + // Se a random secret or the secret specified in the command flags, if any. + var secret, message string + var err error + + if secret = frm.Secret(); secret == "" { + secret, err = client.NewSecret() + message = fmt.Sprintf(ClientSecretInfo, "FOLLOWING RANDOMLY GENERATED") } else { - // Show client authentication credentials. - fmt.Printf("\nPLEASE WRITE DOWN THE FOLLOWING RANDOMLY GENERATED CLIENT SECRET, AS YOU WILL NOT BE ABLE TO SEE IT AGAIN:\n") - result := report.Credentials("Client ID", client.ClientUID, "Client Secret", secret) - fmt.Printf("\n%s\n", result) + err = client.SetSecret(secret) + message = fmt.Sprintf(ClientSecretInfo, "SPECIFIED") } + // 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 }) } diff --git a/internal/commands/clients_list.go b/internal/commands/clients_list.go index 07ff8097e..f6a92d76c 100644 --- a/internal/commands/clients_list.go +++ b/internal/commands/clients_list.go @@ -23,7 +23,7 @@ var ClientsListCommand = cli.Command{ // clientsListAction lists registered client applications func clientsListAction(ctx *cli.Context) 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. clients, err := query.Clients(ctx.Int("n"), 0, "", ctx.Args().First()) @@ -45,14 +45,17 @@ func clientsListAction(ctx *cli.Context) error { // Display report. for i, client := range clients { var authExpires string + if client.AuthExpires > 0 { authExpires = client.Expires().String() - } else { - authExpires = report.Never } 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{ diff --git a/internal/commands/clients_list_test.go b/internal/commands/clients_list_test.go index e565adecc..7e0cc8ff5 100644 --- a/internal/commands/clients_list_test.go +++ b/internal/commands/clients_list_test.go @@ -62,7 +62,7 @@ func TestClientsListCommand(t *testing.T) { // Check command output for plausibility. // t.Logf(output) 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, "metrics") assert.NotContains(t, output, "bob") diff --git a/internal/commands/clients_mod.go b/internal/commands/clients_mod.go index deb6b49e4..58b2ede31 100644 --- a/internal/commands/clients_mod.go +++ b/internal/commands/clients_mod.go @@ -7,7 +7,7 @@ import ( "github.com/photoprism/photoprism/internal/config" "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/report" ) @@ -26,10 +26,10 @@ func clientsModAction(ctx *cli.Context) error { return CallWithDependencies(ctx, func(conf *config.Config) error { conf.MigrateDb(false, nil) - id := clean.UID(ctx.Args().First()) + frm := form.ModClientFromCli(ctx) // Name or UID provided? - if id == "" { + if frm.ID() == "" { log.Infof("no valid client id specified") return cli.ShowSubcommandHelp(ctx) } @@ -37,73 +37,58 @@ func clientsModAction(ctx *cli.Context) error { // Find client record. var client *entity.Client - client = entity.FindClientByUID(id) + client = entity.FindClientByUID(frm.ID()) 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 != "" { - client.ClientName = name - } + // Update client from form values. + client.SetFormValues(frm) - if ctx.IsSet("method") { - client.AuthMethod = authn.Method(ctx.String("method")).String() - } - - 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 + if ctx.IsSet("enable") || ctx.IsSet("disable") { + client.AuthEnabled = frm.AuthEnabled 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 { - return fmt.Errorf("invalid client settings: %s", err) + return fmt.Errorf("invalid values: %s", err) } else if err = client.Save(); err != nil { - return fmt.Errorf("failed to update client settings: %s", err) + return err } else { log.Infof("client %s has been updated", clean.LogQuote(client.ClientName)) } - // Regenerate and display secret, if requested. - if ctx.IsSet("regenerate-secret") && ctx.Bool("regenerate-secret") { - if secret, err := client.NewSecret(); err != nil { - // Failed to create client secret. - return fmt.Errorf("failed to create client secret: %s", err) - } else { - // Show client authentication credentials. - 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") + // Change client secret if requested. + var secret, message string + var err error + + if ctx.IsSet("regenerate") && ctx.Bool("regenerate") { + if secret, err = client.NewSecret(); err != nil { + return fmt.Errorf("failed to regenerate client secret: %s", err) } + + 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 diff --git a/internal/entity/auth_client.go b/internal/entity/auth_client.go index a2fcba3c5..e841f09a6 100644 --- a/internal/entity/auth_client.go +++ b/internal/entity/auth_client.go @@ -14,6 +14,7 @@ import ( "github.com/photoprism/photoprism/pkg/authn" "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/rnd" + "github.com/photoprism/photoprism/pkg/unix" ) // ClientUID is the unique ID prefix. @@ -64,7 +65,7 @@ func NewClient() *Client { AuthProvider: authn.ProviderClientCredentials.String(), AuthMethod: authn.MethodOAuth2.String(), AuthScope: "", - AuthExpires: UnixHour, + AuthExpires: unix.Hour, AuthTokens: 5, AuthEnabled: true, LastActive: 0, @@ -113,9 +114,20 @@ func (m *Client) Name() string { 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. 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 } @@ -173,7 +185,7 @@ func (m *Client) UserInfo() string { if m == nil { return "" } else if m.UserUID == "" { - return "" + return "n/a" } else if m.UserName != "" { return m.UserName } @@ -242,21 +254,36 @@ func (m *Client) Updates(values interface{}) error { return UnscopedDb().Model(m).Updates(values).Error } -// NewSecret sets a new secret stored as hash. -func (m *Client) NewSecret() (s string, err error) { +// NewSecret sets a random client secret and returns it if successful. +func (m *Client) NewSecret() (secret string, err error) { if !m.HasUID() { return "", fmt.Errorf("invalid client uid") } - s = rnd.Base62(32) + secret = rnd.ClientSecret() - pw := NewPassword(m.ClientUID, s, false) - - if err = pw.Save(); err != nil { + if err = m.SetSecret(secret); err != nil { 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. @@ -296,19 +323,37 @@ func (m *Client) Provider() authn.ProviderType { 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. func (m *Client) Method() authn.MethodType { 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. func (m *Client) Scope() string { 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 { - m.AuthScope = clean.Scope(s) + if s = clean.Scope(s); s != "" { + m.AuthScope = clean.Scope(s) + } return m } @@ -318,7 +363,7 @@ func (m *Client) UpdateLastActive() *Client { return m } - m.LastActive = UnixTime() + m.LastActive = unix.Time() if err := Db().Model(m).UpdateColumn("LastActive", m.LastActive).Error; err != nil { 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 } +// 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. func (m *Client) Report(skipEmpty bool) (rows [][]string, cols []string) { 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. func (m *Client) SetFormValues(frm form.Client) *Client { 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 { m.SetUser(u) } - if frm.ClientName != "" { - m.ClientName = frm.Name() + // Set custom client UID? + if id := frm.ID(); m.ClientUID == "" && id != "" { + m.ClientUID = id } - if frm.ClientRole != "" { - m.SetRole(frm.ClientRole) - } - - if frm.AuthProvider != "" { - m.AuthProvider = frm.Provider().String() - } - - 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 - } + // Set values from form. + m.SetName(frm.Name()) + m.SetProvider(frm.Provider()) + m.SetMethod(frm.Method()) + m.SetScope(frm.Scope()) + m.SetTokens(frm.Tokens()) + m.SetExpires(frm.Expires()) + // Enable authentication? if frm.AuthEnabled { 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 } diff --git a/internal/entity/auth_client_add.go b/internal/entity/auth_client_add.go index 1b36c6ec7..deaf663a7 100644 --- a/internal/entity/auth_client_add.go +++ b/internal/entity/auth_client_add.go @@ -1,11 +1,17 @@ package entity import ( + "fmt" + "github.com/photoprism/photoprism/internal/form" ) // AddClient creates a new client and returns it if successful. 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) if err = client.Validate(); err != nil { diff --git a/internal/entity/auth_client_fixtures.go b/internal/entity/auth_client_fixtures.go index 2bb9226a0..3f5fc7d6c 100644 --- a/internal/entity/auth_client_fixtures.go +++ b/internal/entity/auth_client_fixtures.go @@ -3,6 +3,7 @@ package entity import ( "github.com/photoprism/photoprism/internal/acl" "github.com/photoprism/photoprism/pkg/authn" + "github.com/photoprism/photoprism/pkg/unix" ) type ClientMap map[string]Client @@ -37,7 +38,7 @@ var ClientFixtures = ClientMap{ AuthProvider: authn.ProviderClientCredentials.String(), AuthMethod: authn.MethodOAuth2.String(), AuthScope: "*", - AuthExpires: UnixDay, + AuthExpires: unix.Day, AuthTokens: -1, AuthEnabled: true, LastActive: 0, @@ -73,7 +74,7 @@ var ClientFixtures = ClientMap{ AuthProvider: authn.ProviderClientCredentials.String(), AuthMethod: authn.MethodOAuth2.String(), AuthScope: "metrics", - AuthExpires: UnixHour, + AuthExpires: unix.Hour, AuthTokens: 2, AuthEnabled: true, LastActive: 0, @@ -91,7 +92,7 @@ var ClientFixtures = ClientMap{ AuthProvider: authn.ProviderClientCredentials.String(), AuthMethod: authn.MethodUnknown.String(), AuthScope: "*", - AuthExpires: UnixHour, + AuthExpires: unix.Hour, AuthTokens: 2, AuthEnabled: true, LastActive: 0, @@ -109,7 +110,7 @@ var ClientFixtures = ClientMap{ AuthProvider: authn.ProviderClientCredentials.String(), AuthMethod: authn.MethodOAuth2.String(), AuthScope: "metrics", - AuthExpires: UnixHour, + AuthExpires: unix.Hour, AuthTokens: 2, AuthEnabled: true, LastActive: 0, @@ -128,7 +129,7 @@ var ClientFixtures = ClientMap{ AuthProvider: authn.ProviderClientCredentials.String(), AuthMethod: authn.MethodOAuth2.String(), AuthScope: "statistics", - AuthExpires: UnixHour, + AuthExpires: unix.Hour, AuthTokens: 2, AuthEnabled: true, LastActive: 0, diff --git a/internal/entity/auth_client_test.go b/internal/entity/auth_client_test.go index 09b8a0d7e..859d429d7 100644 --- a/internal/entity/auth_client_test.go +++ b/internal/entity/auth_client_test.go @@ -380,13 +380,13 @@ func TestClient_Expires(t *testing.T) { func TestClient_UserInfo(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) { assert.Equal(t, "alice", ClientFixtures.Pointer("alice").UserInfo()) }) t.Run("Metrics", func(t *testing.T) { - assert.Equal(t, "", ClientFixtures.Pointer("metrics").UserInfo()) + assert.Equal(t, "n/a", ClientFixtures.Pointer("metrics").UserInfo()) }) } diff --git a/internal/entity/auth_session.go b/internal/entity/auth_session.go index a6ea3d6e1..74b3e5986 100644 --- a/internal/entity/auth_session.go +++ b/internal/entity/auth_session.go @@ -19,6 +19,7 @@ import ( "github.com/photoprism/photoprism/pkg/list" "github.com/photoprism/photoprism/pkg/rnd" "github.com/photoprism/photoprism/pkg/txt" + "github.com/photoprism/photoprism/pkg/unix" ) // SessionPrefix for RefID. @@ -101,7 +102,7 @@ func (m *Session) Expires(t time.Time) *Session { func DeleteExpiredSessions() (deleted int) { 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) return deleted } @@ -782,7 +783,7 @@ func (m *Session) ExpiresIn() int64 { return 0 } - return m.SessExpires - UnixTime() + return m.SessExpires - unix.Time() } // TimeoutAt returns the time at which the session will expire due to inactivity. @@ -822,7 +823,7 @@ func (m *Session) UpdateLastActive() *Session { return m } - m.LastActive = UnixTime() + m.LastActive = unix.Time() 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) diff --git a/internal/entity/auth_session_cache.go b/internal/entity/auth_session_cache.go index bec9e12df..6d7c01d5e 100644 --- a/internal/entity/auth_session_cache.go +++ b/internal/entity/auth_session_cache.go @@ -9,6 +9,7 @@ import ( "github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/rnd" + "github.com/photoprism/photoprism/pkg/unix" ) // 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. if cacheData, ok := sessionCache.Get(id); ok && cacheData != nil { if cached := cacheData.(*Session); !cached.Expired() { - cached.LastActive = UnixTime() + cached.LastActive = unix.Time() return cached, nil } else if err := cached.Delete(); err != nil { event.AuditErr([]string{cached.IP(), "session %s", "failed to delete after expiration", "%s"}, cached.RefID, err) diff --git a/internal/entity/auth_session_client_test.go b/internal/entity/auth_session_client_test.go index 3995c07e2..57482b62c 100644 --- a/internal/entity/auth_session_client_test.go +++ b/internal/entity/auth_session_client_test.go @@ -4,11 +4,13 @@ import ( "testing" "github.com/stretchr/testify/assert" + + "github.com/photoprism/photoprism/pkg/unix" ) func TestNewClientAuthentication(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 { t.Fatal("session must not be nil") @@ -23,7 +25,7 @@ func TestNewClientAuthentication(t *testing.T) { t.Fatal("user must not be nil") } - sess := NewClientAuthentication("alice", UnixDay, "metrics", user) + sess := NewClientAuthentication("alice", unix.Day, "metrics", user) if sess == nil { t.Fatal("session must not be nil") @@ -38,7 +40,7 @@ func TestNewClientAuthentication(t *testing.T) { t.Fatal("user must not be nil") } - sess := NewClientAuthentication("alice", UnixDay, "", user) + sess := NewClientAuthentication("alice", unix.Day, "", user) if sess == nil { t.Fatal("session must not be nil") @@ -65,7 +67,7 @@ func TestNewClientAuthentication(t *testing.T) { func TestAddClientAuthentication(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) @@ -82,7 +84,7 @@ func TestAddClientAuthentication(t *testing.T) { 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) diff --git a/internal/entity/auth_session_fixtures.go b/internal/entity/auth_session_fixtures.go index 539ec570d..830dddac4 100644 --- a/internal/entity/auth_session_fixtures.go +++ b/internal/entity/auth_session_fixtures.go @@ -4,6 +4,7 @@ import ( "github.com/photoprism/photoprism/pkg/authn" "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/rnd" + "github.com/photoprism/photoprism/pkg/unix" ) type SessionMap map[string]Session @@ -29,8 +30,8 @@ var SessionFixtures = SessionMap{ authToken: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", ID: rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0"), RefID: "sessxkkcabcd", - SessTimeout: UnixDay * 3, - SessExpires: UnixTime() + UnixWeek, + SessTimeout: unix.Day * 3, + SessExpires: unix.Time() + unix.Week, user: UserFixtures.Pointer("alice"), UserUID: UserFixtures.Pointer("alice").UserUID, UserName: UserFixtures.Pointer("alice").UserName, @@ -40,7 +41,7 @@ var SessionFixtures = SessionMap{ ID: rnd.SessionID("bb8658e779403ae524a188712470060f050054324a8b104e"), RefID: "sess34q3hael", SessTimeout: -1, - SessExpires: UnixTime() + UnixDay, + SessExpires: unix.Time() + unix.Day, AuthScope: clean.Scope("*"), AuthProvider: authn.ProviderAccessToken.String(), AuthMethod: authn.MethodDefault.String(), @@ -55,7 +56,7 @@ var SessionFixtures = SessionMap{ ID: rnd.SessionID("DIbS8T-uyGMe1-R3fmTv-vVaR35"), RefID: "sess6ey1ykya", SessTimeout: -1, - SessExpires: UnixTime() + UnixDay, + SessExpires: unix.Time() + unix.Day, AuthScope: clean.Scope("*"), AuthProvider: authn.ProviderAccessToken.String(), AuthMethod: authn.MethodPersonal.String(), @@ -70,7 +71,7 @@ var SessionFixtures = SessionMap{ ID: rnd.SessionID("5d0rGx-EvsDnV-DcKtYY-HT1aWL"), RefID: "sesshjtgx8qt", SessTimeout: -1, - SessExpires: UnixTime() + UnixDay, + SessExpires: unix.Time() + unix.Day, AuthScope: clean.Scope("webdav"), AuthProvider: authn.ProviderAccessToken.String(), AuthMethod: authn.MethodPersonal.String(), @@ -85,7 +86,7 @@ var SessionFixtures = SessionMap{ ID: rnd.SessionID("778f0f7d80579a072836c65b786145d6e0127505194cc51e"), RefID: "sessjr0ge18d", SessTimeout: 0, - SessExpires: UnixTime() + UnixDay, + SessExpires: unix.Time() + unix.Day, AuthScope: clean.Scope("metrics photos albums videos"), AuthProvider: authn.ProviderAccessToken.String(), AuthMethod: authn.MethodDefault.String(), @@ -100,8 +101,8 @@ var SessionFixtures = SessionMap{ authToken: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1", ID: rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1"), RefID: "sessxkkcabce", - SessTimeout: UnixDay * 3, - SessExpires: UnixTime() + UnixWeek, + SessTimeout: unix.Day * 3, + SessExpires: unix.Time() + unix.Week, user: UserFixtures.Pointer("bob"), UserUID: UserFixtures.Pointer("bob").UserUID, UserName: UserFixtures.Pointer("bob").UserName, @@ -110,8 +111,8 @@ var SessionFixtures = SessionMap{ authToken: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac2", ID: rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac2"), RefID: "sessxkkcabcf", - SessTimeout: UnixDay * 3, - SessExpires: UnixTime() + UnixWeek, + SessTimeout: unix.Day * 3, + SessExpires: unix.Time() + unix.Week, user: UserFixtures.Pointer("unauthorized"), UserUID: UserFixtures.Pointer("unauthorized").UserUID, UserName: UserFixtures.Pointer("unauthorized").UserName, @@ -120,8 +121,8 @@ var SessionFixtures = SessionMap{ authToken: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac3", ID: rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac3"), RefID: "sessxkkcabcg", - SessTimeout: UnixDay * 3, - SessExpires: UnixTime() + UnixWeek, + SessTimeout: unix.Day * 3, + SessExpires: unix.Time() + unix.Week, user: &Visitor, UserUID: Visitor.UserUID, UserName: Visitor.UserName, @@ -136,7 +137,7 @@ var SessionFixtures = SessionMap{ ID: rnd.SessionID("4ebe1048a7384e1e6af2930b5b6f29795ffab691df47a488"), RefID: "sessaae5cxun", SessTimeout: 0, - SessExpires: UnixTime() + UnixWeek, + SessExpires: unix.Time() + unix.Week, AuthScope: clean.Scope("metrics"), AuthProvider: authn.ProviderAccessToken.String(), AuthMethod: authn.MethodDefault.String(), @@ -149,8 +150,8 @@ var SessionFixtures = SessionMap{ authToken: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac4", ID: rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac4"), RefID: "sessxkkcabch", - SessTimeout: UnixDay * 3, - SessExpires: UnixTime() + UnixWeek, + SessTimeout: unix.Day * 3, + SessExpires: unix.Time() + unix.Week, user: UserFixtures.Pointer("friend"), UserUID: UserFixtures.Pointer("friend").UserUID, UserName: UserFixtures.Pointer("friend").UserName, @@ -160,7 +161,7 @@ var SessionFixtures = SessionMap{ ID: rnd.SessionID("9d8b8801ffa23eb52e08ca7766283799ddfd8dd368212345"), RefID: "sessgh612345", SessTimeout: 0, - SessExpires: UnixTime() + UnixWeek, + SessExpires: unix.Time() + unix.Week, AuthScope: clean.Scope("metrics"), AuthProvider: authn.ProviderClientCredentials.String(), AuthMethod: authn.MethodOAuth2.String(), @@ -177,7 +178,7 @@ var SessionFixtures = SessionMap{ ID: rnd.SessionID("9d8b8801ffa23eb52e08ca7766283799ddfd8dd368208a9b"), RefID: "sessgh6gjuo1", SessTimeout: 0, - SessExpires: UnixTime() + UnixWeek, + SessExpires: unix.Time() + unix.Week, AuthScope: clean.Scope("metrics"), AuthProvider: authn.ProviderAccessToken.String(), AuthMethod: authn.MethodDefault.String(), @@ -193,7 +194,7 @@ var SessionFixtures = SessionMap{ ID: rnd.SessionID("3f9684f7d3dd3d5b84edd43289c7fb5ca32ee73bd0233237"), RefID: "sessyugn54so", SessTimeout: 0, - SessExpires: UnixTime() + UnixWeek, + SessExpires: unix.Time() + unix.Week, AuthScope: clean.Scope("settings"), AuthProvider: authn.ProviderAccessToken.String(), AuthMethod: authn.MethodDefault.String(), @@ -209,7 +210,7 @@ var SessionFixtures = SessionMap{ ID: rnd.SessionID("9d8b8801ffa23eb52e08ca7766283799ddfd8dd368212123"), RefID: "sessgh6123yt", SessTimeout: 0, - SessExpires: UnixTime() + UnixWeek, + SessExpires: unix.Time() + unix.Week, AuthScope: clean.Scope("statistics"), AuthProvider: authn.ProviderClientCredentials.String(), AuthMethod: authn.MethodOAuth2.String(), diff --git a/internal/entity/auth_session_login_test.go b/internal/entity/auth_session_login_test.go index 7ae344c91..c5d33b8ba 100644 --- a/internal/entity/auth_session_login_test.go +++ b/internal/entity/auth_session_login_test.go @@ -12,6 +12,7 @@ import ( "github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/pkg/authn" "github.com/photoprism/photoprism/pkg/rnd" + "github.com/photoprism/photoprism/pkg/unix" ) func TestAuthSession(t *testing.T) { @@ -275,7 +276,7 @@ func TestSessionLogIn(t *testing.T) { rec := httptest.NewRecorder() t.Run("Admin", func(t *testing.T) { - m := NewSession(UnixDay, UnixHour*6) + m := NewSession(unix.Day, unix.Hour*6) m.SetClientIP(clientIp) // Create login form. @@ -295,7 +296,7 @@ func TestSessionLogIn(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) // Create login form. @@ -315,7 +316,7 @@ func TestSessionLogIn(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) // Create login form. @@ -335,7 +336,7 @@ func TestSessionLogIn(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) // Create login form. @@ -355,7 +356,7 @@ func TestSessionLogIn(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) // Create login form. diff --git a/internal/entity/auth_session_test.go b/internal/entity/auth_session_test.go index 86727214b..907d49118 100644 --- a/internal/entity/auth_session_test.go +++ b/internal/entity/auth_session_test.go @@ -11,11 +11,12 @@ import ( "github.com/photoprism/photoprism/pkg/authn" "github.com/photoprism/photoprism/pkg/header" "github.com/photoprism/photoprism/pkg/rnd" + "github.com/photoprism/photoprism/pkg/unix" ) func TestNewSession(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.IsSessionID(m.ID)) @@ -27,7 +28,7 @@ func TestNewSession(t *testing.T) { assert.Equal(t, 0, len(m.Data().Tokens)) }) t.Run("EmptySessionData", func(t *testing.T) { - m := NewSession(UnixDay, UnixHour*6) + m := NewSession(unix.Day, unix.Hour*6) m.SetData(NewSessionData()) assert.True(t, rnd.IsAuthToken(m.AuthToken())) @@ -42,7 +43,7 @@ func TestNewSession(t *testing.T) { t.Run("WithSessionData", func(t *testing.T) { data := NewSessionData() data.Tokens = []string{"foo", "bar"} - m := NewSession(UnixDay, UnixHour*6) + m := NewSession(unix.Day, unix.Hour*6) m.SetData(data) assert.True(t, rnd.IsAuthToken(m.AuthToken())) @@ -60,7 +61,7 @@ func TestNewSession(t *testing.T) { func TestSession_SetData(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) @@ -74,7 +75,7 @@ func TestSession_SetData(t *testing.T) { func TestSession_Expires(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 m.Expires(time.Date(2035, 01, 15, 12, 30, 0, 0, time.UTC)) finalExpiryDate := m.SessExpires @@ -82,7 +83,7 @@ func TestSession_Expires(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 m.Expires(time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)) finalExpiryDate := m.SessExpires @@ -92,7 +93,7 @@ func TestSession_Expires(t *testing.T) { func TestDeleteExpiredSessions(t *testing.T) { 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.Save() assert.Equal(t, 1, DeleteExpiredSessions()) @@ -161,7 +162,7 @@ func TestFindSessionByRefID(t *testing.T) { func TestSession_Regenerate(t *testing.T) { t.Run("NewSession", func(t *testing.T) { - m := NewSession(UnixDay, UnixHour) + m := NewSession(unix.Day, unix.Hour) initialID := m.ID m.Regenerate() finalID := m.ID @@ -226,8 +227,8 @@ func TestSession_Create(t *testing.T) { assert.Empty(t, m) s := &Session{ UserName: "charles", - SessExpires: UnixDay * 3, - SessTimeout: UnixTime() + UnixWeek, + SessExpires: unix.Day * 3, + SessTimeout: unix.Time() + unix.Week, RefID: "sessxkkcxxxx", } @@ -252,8 +253,8 @@ func TestSession_Create(t *testing.T) { s := &Session{ UserName: "charles", - SessExpires: UnixDay * 3, - SessTimeout: UnixTime() + UnixWeek, + SessExpires: unix.Day * 3, + SessTimeout: unix.Time() + unix.Week, RefID: "123", } @@ -274,8 +275,8 @@ func TestSession_Create(t *testing.T) { s := &Session{ UserName: "charles", - SessExpires: UnixDay * 3, - SessTimeout: UnixTime() + UnixWeek, + SessExpires: unix.Day * 3, + SessTimeout: unix.Time() + unix.Week, RefID: "sessxkkcxxxx", } @@ -292,8 +293,8 @@ func TestSession_Save(t *testing.T) { assert.Empty(t, m) s := &Session{ UserName: "chris", - SessExpires: UnixDay * 3, - SessTimeout: UnixTime() + UnixWeek, + SessExpires: unix.Day * 3, + SessTimeout: unix.Time() + unix.Week, RefID: "sessxkkcxxxy", } @@ -743,14 +744,14 @@ func TestSession_RedeemToken(t *testing.T) { func TestSession_TimedOut(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.Equal(t, m.ExpiresAt(), m.TimeoutAt()) assert.False(t, m.TimedOut()) assert.Greater(t, m.ExpiresIn(), int64(0)) }) 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()) assert.True(t, m.TimeoutAt().IsZero()) assert.Equal(t, m.ExpiresAt(), m.TimeoutAt()) @@ -759,7 +760,7 @@ func TestSession_TimedOut(t *testing.T) { assert.Equal(t, m.ExpiresIn(), int64(0)) }) 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()) assert.False(t, m.TimeoutAt().IsZero()) assert.Equal(t, m.ExpiresAt(), m.TimeoutAt()) @@ -768,19 +769,19 @@ func TestSession_TimedOut(t *testing.T) { assert.Greater(t, m.ExpiresIn(), int64(0)) }) t.Run("TimedOut", func(t *testing.T) { - m := NewSession(UnixDay, UnixHour) - utc := UnixTime() + m := NewSession(unix.Day, unix.Hour) + utc := unix.Time() - m.LastActive = utc - (UnixHour + 1) + m.LastActive = utc - (unix.Hour + 1) assert.False(t, m.TimeoutAt().IsZero()) assert.True(t, m.TimedOut()) }) t.Run("NotTimedOut", func(t *testing.T) { - m := NewSession(UnixDay, UnixHour) - utc := UnixTime() + m := NewSession(unix.Day, unix.Hour) + utc := unix.Time() - m.LastActive = utc - (UnixHour - 10) + m.LastActive = utc - (unix.Hour - 10) assert.False(t, m.TimeoutAt().IsZero()) assert.False(t, m.TimedOut()) @@ -789,7 +790,7 @@ func TestSession_TimedOut(t *testing.T) { func TestSession_Expired(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()) assert.False(t, m.ExpiresAt().IsZero()) assert.False(t, m.Expired()) @@ -813,9 +814,9 @@ func TestSession_Expired(t *testing.T) { assert.False(t, m.TimedOut()) }) 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()) - utc := UnixTime() + utc := unix.Time() m.SessExpires = utc - 10 @@ -826,8 +827,8 @@ func TestSession_Expired(t *testing.T) { assert.Equal(t, m.ExpiresAt(), m.TimeoutAt()) }) t.Run("NotExpired", func(t *testing.T) { - m := NewSession(UnixDay, UnixHour) - utc := UnixTime() + m := NewSession(unix.Day, unix.Hour) + utc := unix.Time() m.SessExpires = utc + 10 diff --git a/internal/entity/entity_time.go b/internal/entity/entity_time.go index 5fbcdf5b1..0bfc81c9e 100644 --- a/internal/entity/entity_time.go +++ b/internal/entity/entity_time.go @@ -7,34 +7,11 @@ import ( // Day specified as time.Duration to improve readability. 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). func UTC() time.Time { 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. func TimeStamp() time.Time { return UTC().Truncate(time.Second) diff --git a/internal/form/client.go b/internal/form/client.go index cc05f8239..bdb753012 100644 --- a/internal/form/client.go +++ b/internal/form/client.go @@ -3,15 +3,19 @@ package form import ( "github.com/urfave/cli" + "github.com/photoprism/photoprism/internal/acl" "github.com/photoprism/photoprism/pkg/authn" "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/rnd" + "github.com/photoprism/photoprism/pkg/unix" ) // Client represents client application settings. type Client struct { UserUID string `json:"UserUID,omitempty" yaml:"UserUID,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"` ClientRole string `json:"ClientRole,omitempty" yaml:"ClientRole,omitempty"` AuthProvider string `json:"AuthProvider,omitempty" yaml:"AuthProvider,omitempty"` @@ -27,7 +31,10 @@ func NewClient() Client { return Client{ UserUID: "", UserName: "", + ClientID: "", + ClientSecret: "", ClientName: "", + ClientRole: acl.RoleClient.String(), AuthProvider: authn.ProviderClientCredentials.String(), AuthMethod: authn.MethodOAuth2.String(), AuthScope: "", @@ -37,29 +44,124 @@ func NewClient() Client { } } -// NewClientFromCli creates a new form with values from a CLI context. -func NewClientFromCli(ctx *cli.Context) Client { +// AddClientFromCli creates a new form for adding a client with values from the specified CLI context. +func AddClientFromCli(ctx *cli.Context) Client { 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') { f.UserUID = user } else if 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 } +// 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. func (f *Client) Name() string { return clean.Name(f.ClientName) @@ -84,3 +186,29 @@ func (f *Client) Method() authn.MethodType { func (f Client) Scope() string { 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 +} diff --git a/internal/form/client_test.go b/internal/form/client_test.go index 04f01f348..83e619e82 100644 --- a/internal/form/client_test.go +++ b/internal/form/client_test.go @@ -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) { - globalSet := flag.NewFlagSet("test", 0) - globalSet.String("name", "Test", "") - globalSet.String("scope", "*", "") - globalSet.String("provider", "client_credentials", "") - globalSet.String("method", "totp", "") + // Create new context with flags. + ctx := cli.NewContext(cli.NewApp(), flags, nil) - app := cli.NewApp() - app.Version = "0.0.0" + // 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")) - 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.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()) }) } diff --git a/internal/query/sessions.go b/internal/query/sessions.go index 758f32c47..2b6f77a63 100644 --- a/internal/query/sessions.go +++ b/internal/query/sessions.go @@ -6,6 +6,7 @@ import ( "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/pkg/rnd" + "github.com/photoprism/photoprism/pkg/unix" ) // 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) 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) { stmt = stmt.Where("id = ?", search) } else if rnd.IsAuthToken(search) { diff --git a/internal/session/session_save.go b/internal/session/session_save.go index 07eb8b49e..67156aa5b 100644 --- a/internal/session/session_save.go +++ b/internal/session/session_save.go @@ -6,6 +6,7 @@ import ( "github.com/gin-gonic/gin" "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/pkg/unix" ) // 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. - m.LastActive = entity.UnixTime() + m.LastActive = unix.Time() // Save session. err := m.Save() diff --git a/pkg/authn/methods.go b/pkg/authn/methods.go index ce3334f23..6d2bc6e2e 100644 --- a/pkg/authn/methods.go +++ b/pkg/authn/methods.go @@ -21,6 +21,11 @@ const ( MethodUnknown MethodType = "" ) +// IsUnknown checks if the method is unknown. +func (t MethodType) IsUnknown() bool { + return t == "" +} + // IsDefault checks if this is the default method. func (t MethodType) IsDefault() bool { return t.String() == MethodDefault.String() diff --git a/pkg/authn/providers.go b/pkg/authn/providers.go index 2559283d7..e9f7abe3d 100644 --- a/pkg/authn/providers.go +++ b/pkg/authn/providers.go @@ -43,6 +43,11 @@ var ClientProviders = list.List{ string(ProviderAccessToken), } +// IsUnknown checks if the provider is unknown. +func (t ProviderType) IsUnknown() bool { + return t == "" +} + // IsRemote checks if the provider is external. func (t ProviderType) IsRemote() bool { return list.Contains(RemoteProviders, string(t)) diff --git a/pkg/header/cache.go b/pkg/header/cache.go index 530af2b8c..35bf20085 100644 --- a/pkg/header/cache.go +++ b/pkg/header/cache.go @@ -4,6 +4,8 @@ import ( "strconv" "github.com/gin-gonic/gin" + + "github.com/photoprism/photoprism/pkg/unix" ) const ( @@ -48,8 +50,8 @@ var ( func CacheControlMaxAge(maxAge int, public bool) string { if maxAge < 0 { return CacheControlNoCache - } else if maxAge > 31536000 { - maxAge = 31536000 + } else if maxAge > unix.YearInt { + maxAge = unix.YearInt } switch { diff --git a/pkg/header/cache_test.go b/pkg/header/cache_test.go index 681dae89b..be0338e2a 100644 --- a/pkg/header/cache_test.go +++ b/pkg/header/cache_test.go @@ -6,8 +6,9 @@ import ( "testing" "github.com/gin-gonic/gin" - "github.com/stretchr/testify/assert" + + "github.com/photoprism/photoprism/pkg/unix" ) func TestCacheControlMaxAge(t *testing.T) { @@ -15,27 +16,27 @@ func TestCacheControlMaxAge(t *testing.T) { assert.Equal(t, CacheControlPrivateDefault, CacheControlMaxAge(0, 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=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)) }) t.Run("Public", func(t *testing.T) { assert.Equal(t, CacheControlPublicDefault, CacheControlMaxAge(0, 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=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)) }) } func BenchmarkTestCacheControlMaxAge(b *testing.B) { for n := 0; n < b.N; n++ { - _ = CacheControlMaxAge(31536000, false) + _ = CacheControlMaxAge(unix.YearInt, false) } } func BenchmarkTestCacheControlMaxAgeImmutable(b *testing.B) { 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), } - SetCacheControl(c, 31536000, false) + SetCacheControl(c, unix.YearInt, false) assert.Equal(t, "private, max-age=31536000", c.Writer.Header().Get(CacheControl)) }) t.Run("Public", func(t *testing.T) { @@ -59,7 +60,7 @@ func TestSetCacheControl(t *testing.T) { 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)) }) t.Run("NoCache", func(t *testing.T) { @@ -84,7 +85,7 @@ func TestSetCacheControlImmutable(t *testing.T) { 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)) }) t.Run("Public", func(t *testing.T) { @@ -95,7 +96,7 @@ func TestSetCacheControlImmutable(t *testing.T) { 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)) }) t.Run("PublicDefault", func(t *testing.T) { diff --git a/pkg/sev/severity.go b/pkg/level/level.go similarity index 71% rename from pkg/sev/severity.go rename to pkg/level/level.go index 9f4f9f3c7..5ec961128 100644 --- a/pkg/sev/severity.go +++ b/pkg/level/level.go @@ -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. @@ -22,16 +22,4 @@ want to support our work, or just want to say hello. Additional information can be found in our Developer Guide: */ -package sev - -// 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" - } -} +package level diff --git a/pkg/sev/levels.go b/pkg/level/levels.go similarity index 70% rename from pkg/sev/levels.go rename to pkg/level/levels.go index 746089dd0..c67e037a6 100644 --- a/pkg/sev/levels.go +++ b/pkg/level/levels.go @@ -1,11 +1,12 @@ -package sev +package level import ( "fmt" ) +// Severity levels. const ( - Emergency Level = iota + Emergency Severity = iota Alert Critical Error @@ -15,7 +16,8 @@ const ( Debug ) -var Levels = []Level{ +// Levels contains the valid severity levels. +var Levels = []Severity{ Emergency, Alert, Critical, @@ -27,7 +29,7 @@ var Levels = []Level{ } // UnmarshalText implements encoding.TextUnmarshaler. -func (level *Level) UnmarshalText(text []byte) error { +func (level *Severity) UnmarshalText(text []byte) error { l, err := Parse(string(text)) if err != nil { return err @@ -38,7 +40,8 @@ func (level *Level) UnmarshalText(text []byte) error { return nil } -func (level Level) MarshalText() ([]byte, error) { +// MarshalText implements encoding.TextMarshaler. +func (level Severity) MarshalText() ([]byte, error) { switch level { case Debug: 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) } -func (level Level) Status() string { +// Status returns the severity level as an info string for reports. +func (level Severity) Status() string { switch level { case Warning: return "warning" diff --git a/pkg/sev/logrus.go b/pkg/level/logrus.go similarity index 65% rename from pkg/sev/logrus.go rename to pkg/level/logrus.go index f87d7f24f..6228112ac 100644 --- a/pkg/sev/logrus.go +++ b/pkg/level/logrus.go @@ -1,10 +1,10 @@ -package sev +package level import "github.com/sirupsen/logrus" -// LogLevel takes a logrus log level and returns the severity. -func LogLevel(lvl logrus.Level) Level { - switch lvl { +// Logrus takes a logrus.Level and returns the corresponding Severity. +func Logrus(level logrus.Level) Severity { + switch level { case logrus.PanicLevel: return Alert case logrus.FatalLevel: diff --git a/pkg/sev/parse.go b/pkg/level/parse.go similarity index 63% rename from pkg/sev/parse.go rename to pkg/level/parse.go index 7b6725ed4..a1ccff748 100644 --- a/pkg/sev/parse.go +++ b/pkg/level/parse.go @@ -1,13 +1,13 @@ -package sev +package level import ( "fmt" "strings" ) -// Parse takes a string level and returns the severity constant. -func Parse(lvl string) (Level, error) { - switch strings.ToLower(lvl) { +// Parse takes a string and returns the corresponding severity, if any. +func Parse(level string) (Severity, error) { + switch strings.ToLower(level) { case "emergency", "emerg", "panic": return Emergency, nil case "fatal", "alert": @@ -26,6 +26,6 @@ func Parse(lvl string) (Level, error) { return Debug, nil } - var l Level - return l, fmt.Errorf("not a valid Level: %q", lvl) + var l Severity + return l, fmt.Errorf("not a valid severity level: %q", level) } diff --git a/pkg/level/severity.go b/pkg/level/severity.go new file mode 100644 index 000000000..11c5fb72a --- /dev/null +++ b/pkg/level/severity.go @@ -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" + } +} diff --git a/pkg/sev/severity_test.go b/pkg/level/severity_test.go similarity index 94% rename from pkg/sev/severity_test.go rename to pkg/level/severity_test.go index 31bef7553..e71e69c8d 100644 --- a/pkg/sev/severity_test.go +++ b/pkg/level/severity_test.go @@ -1,4 +1,4 @@ -package sev +package level import ( "bytes" @@ -10,7 +10,7 @@ import ( func TestLevelJsonEncoding(t *testing.T) { type X struct { - Level Level + Level Severity } var x X @@ -24,7 +24,7 @@ func TestLevelJsonEncoding(t *testing.T) { } func TestLevelUnmarshalText(t *testing.T) { - var u Level + var u Severity for _, level := range Levels { t.Run(level.String(), func(t *testing.T) { assert.NoError(t, u.UnmarshalText([]byte(level.String()))) @@ -50,7 +50,7 @@ func TestLevelMarshalText(t *testing.T) { for idx, val := range Levels { level := val t.Run(level.String(), func(t *testing.T) { - var cmp Level + var cmp Severity b, err := level.MarshalText() assert.NoError(t, err) assert.Equal(t, levelStrings[idx], string(b)) diff --git a/pkg/rnd/auth.go b/pkg/rnd/auth.go index 8f5575904..94e90b9f4 100644 --- a/pkg/rnd/auth.go +++ b/pkg/rnd/auth.go @@ -29,7 +29,7 @@ func AuthToken() string { 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 { if l := len(s); l == AuthTokenLength { return IsHex(s) @@ -61,7 +61,7 @@ func AppPassword() string { 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 { // Verify token length. 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])) } -// 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 { // Check if string might be a regular auth token. if IsAuthToken(s) { @@ -109,7 +109,7 @@ func SessionID(token string) string { 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 { if len(id) != SessionIdLength { return false diff --git a/pkg/rnd/client.go b/pkg/rnd/client.go new file mode 100644 index 000000000..f95561cf0 --- /dev/null +++ b/pkg/rnd/client.go @@ -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 +} diff --git a/pkg/rnd/client_test.go b/pkg/rnd/client_test.go new file mode 100644 index 000000000..852b83538 --- /dev/null +++ b/pkg/rnd/client_test.go @@ -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") + } +} diff --git a/pkg/unix/const.go b/pkg/unix/const.go new file mode 100644 index 000000000..b2af13b03 --- /dev/null +++ b/pkg/unix/const.go @@ -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) diff --git a/pkg/unix/time.go b/pkg/unix/time.go new file mode 100644 index 000000000..40944d2ad --- /dev/null +++ b/pkg/unix/time.go @@ -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() +} diff --git a/pkg/unix/time_test.go b/pkg/unix/time_test.go new file mode 100644 index 000000000..3a472c159 --- /dev/null +++ b/pkg/unix/time_test.go @@ -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) +} diff --git a/pkg/unix/unix.go b/pkg/unix/unix.go new file mode 100644 index 000000000..b1acaa617 --- /dev/null +++ b/pkg/unix/unix.go @@ -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"): + + + 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: + + +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: + +*/ +package unix