Skip to content

Commit

Permalink
feat: refresh API key, closes #47
Browse files Browse the repository at this point in the history
  • Loading branch information
YuukanOO committed Apr 23, 2024
1 parent b0f2aae commit bfcec8e
Show file tree
Hide file tree
Showing 14 changed files with 202 additions and 3 deletions.
4 changes: 4 additions & 0 deletions api.http
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ Content-Type: application/json

###

PUT {{url}}/profile/key

###

GET {{url}}/targets

###
Expand Down
2 changes: 2 additions & 0 deletions cmd/serve/front/src/lib/localization/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ You may reconsider and try to make the target reachable before deleting it.`,
'profile.key': 'API Key',
'profile.key.help':
'Pass this token as an <code>Authorization: Bearer</code> header to communicate with the seelf API. <strong>You MUST keep it secret!</strong>',
'profile.key.refresh': 'Regenerate API Key',
'profile.key.refresh.confirm': 'It will make the old key invalid. Do you confirm?',
// Deployment
'deployment.new': 'New deployment',
'deployment.deploy': 'Deploy',
Expand Down
3 changes: 3 additions & 0 deletions cmd/serve/front/src/lib/localization/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,9 @@ Vous devriez probablement essayer de rendre la cible accessible avant de la supp
'profile.key': 'Clé API',
'profile.key.help':
"Passez ce jeton dans l'entête <code>Authorization: Bearer</code> pour communiquer avec l'API seelf. <strong>Vous devez le garder secret !</strong>",
'profile.key.refresh': "Regénérer la clé d'API",
'profile.key.refresh.confirm':
"L'ancienne clé ne sera plus valide. Confirmez-vous cette action ?",
// Deployment
'deployment.new': 'Nouveau déploiement',
'deployment.deploy': 'Déployer',
Expand Down
7 changes: 6 additions & 1 deletion cmd/serve/front/src/lib/resources/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,18 @@ export type UpdateProfileData = {

export interface UsersService {
update(payload: UpdateProfileData): Promise<Profile>;
refreshAPIKey(): Promise<Pick<Profile, 'api_key'>>;
}

export class RemoteUsersService implements UsersService {
constructor(private readonly _fetcher: FetchService) {}

update(payload: UpdateProfileData): Promise<Profile> {
return this._fetcher.patch<Profile, UpdateProfileData>('/api/v1/profile', payload);
return this._fetcher.patch('/api/v1/profile', payload);
}

refreshAPIKey(): Promise<Pick<Profile, 'api_key'>> {
return this._fetcher.put('/api/v1/profile/key');
}
}

Expand Down
22 changes: 20 additions & 2 deletions cmd/serve/front/src/routes/(main)/profile/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,16 @@
import Stack from '$components/stack.svelte';
import TextArea from '$components/text-area.svelte';
import TextInput from '$components/text-input.svelte';
import Dropdown from '$components/dropdown.svelte';
import { submitter } from '$lib/form.js';
import service from '$lib/resources/users';
import l from '$lib/localization';
import Dropdown from '$components/dropdown.svelte';
export let data;
let email = data.user.email;
let password: Maybe<string>;
let apiKey = data.user.api_key;
let locale = l.locale();
const locales = l.locales().map((l) => ({ label: l.displayName, value: l.code }));
Expand All @@ -27,6 +29,14 @@
password: password ? password : undefined
})
.then(() => l.locale(locale));
const {
submit: refreshKey,
loading,
errors: refreshErr
} = submitter(() => service.refreshAPIKey().then((d) => (apiKey = d.api_key)), {
confirmation: l.translate('profile.key.refresh.confirm')
});
</script>

<Form handler={submit} let:submitting let:errors>
Expand Down Expand Up @@ -64,11 +74,19 @@
</FormSection>

<FormSection title="profile.integration">
<Button
slot="actions"
variant="outlined"
on:click={refreshKey}
loading={$loading}
text="profile.key.refresh"
/>
<Stack direction="column">
<FormErrors errors={$refreshErr} />
<Panel title="profile.integration.title" variant="help">
<p>{@html l.translate('profile.integration.description')}</p>
</Panel>
<TextArea label="profile.key" rows={1} code value={data.user.api_key} readonly>
<TextArea label="profile.key" rows={1} code value={apiKey} readonly>
<p>
{@html l.translate('profile.key.help')}
</p>
Expand Down
1 change: 1 addition & 0 deletions cmd/serve/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ func newHttpServer(options ServerOptions, root startup.ServerRoot) *server {
v1secured.DELETE("/jobs/:id", s.deleteJobsHandler())
v1secured.GET("/profile", s.getProfileHandler())
v1secured.PATCH("/profile", s.updateProfileHandler())
v1secured.PUT("/profile/key", s.refreshProfileKeyHandler())
v1secured.POST("/targets", s.createTargetHandler())
v1secured.PATCH("/targets/:id", s.updateTargetHandler())
v1secured.POST("/targets/:id/reconfigure", s.reconfigureTargetHandler())
Expand Down
23 changes: 23 additions & 0 deletions cmd/serve/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package serve

import (
"github.com/YuukanOO/seelf/internal/auth/app/get_profile"
"github.com/YuukanOO/seelf/internal/auth/app/refresh_api_key"
"github.com/YuukanOO/seelf/internal/auth/app/update_user"
"github.com/YuukanOO/seelf/internal/auth/domain"
"github.com/YuukanOO/seelf/pkg/bus"
Expand Down Expand Up @@ -30,6 +31,28 @@ func (s *server) updateProfileHandler() gin.HandlerFunc {
})
}

type refreshProfileKeyResult struct {
APIKey string `json:"api_key"`
}

func (s *server) refreshProfileKeyHandler() gin.HandlerFunc {
return http.Send(s, func(ctx *gin.Context) error {
c := ctx.Request.Context()
currentUserID := domain.CurrentUser(c).MustGet()
key, err := bus.Send(s.bus, c, refresh_api_key.Command{
ID: string(currentUserID),
})

if err != nil {
return err
}

return http.Ok(ctx, refreshProfileKeyResult{
APIKey: key,
})
})
}

func (s *server) getProfileHandler() gin.HandlerFunc {
return http.Send(s, func(ctx *gin.Context) error {
c := ctx.Request.Context()
Expand Down
44 changes: 44 additions & 0 deletions internal/auth/app/refresh_api_key/refresh_api_key.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package refresh_api_key

import (
"context"

"github.com/YuukanOO/seelf/internal/auth/domain"
"github.com/YuukanOO/seelf/pkg/bus"
)

type Command struct {
bus.Command[string]

ID string `json:"-"`
}

func (Command) Name_() string { return "auth.command.refresh_api_key" }

func Handler(
reader domain.UsersReader,
writer domain.UsersWriter,
generator domain.KeyGenerator,
) bus.RequestHandler[string, Command] {
return func(ctx context.Context, cmd Command) (string, error) {
user, err := reader.GetByID(ctx, domain.UserID(cmd.ID))

if err != nil {
return "", err
}

key, err := generator.Generate()

if err != nil {
return "", err
}

user.HasAPIKey(key)

if err = writer.Write(ctx, &user); err != nil {
return "", err
}

return string(key), nil
}
}
48 changes: 48 additions & 0 deletions internal/auth/app/refresh_api_key/refresh_api_key_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package refresh_api_key_test

import (
"context"
"testing"

"github.com/YuukanOO/seelf/internal/auth/app/refresh_api_key"
"github.com/YuukanOO/seelf/internal/auth/domain"
"github.com/YuukanOO/seelf/internal/auth/infra/crypto"
"github.com/YuukanOO/seelf/internal/auth/infra/memory"
"github.com/YuukanOO/seelf/pkg/apperr"
"github.com/YuukanOO/seelf/pkg/bus"
"github.com/YuukanOO/seelf/pkg/must"
"github.com/YuukanOO/seelf/pkg/testutil"
)

func Test_RefreshApiKey(t *testing.T) {
sut := func(existingUsers ...*domain.User) bus.RequestHandler[string, refresh_api_key.Command] {
store := memory.NewUsersStore(existingUsers...)

return refresh_api_key.Handler(store, store, crypto.NewKeyGenerator())
}

t.Run("should fail if the user does not exists", func(t *testing.T) {
uc := sut()

_, err := uc(context.Background(), refresh_api_key.Command{})

testutil.ErrorIs(t, apperr.ErrNotFound, err)
})

t.Run("should refresh the user's API key if everything is good", func(t *testing.T) {
user := must.Panic(domain.NewUser(domain.NewEmailRequirement("[email protected]", true), "someHashedPassword", "apikey"))
uc := sut(&user)

key, err := uc(context.Background(), refresh_api_key.Command{
ID: string(user.ID())},
)

testutil.IsNil(t, err)
testutil.NotEquals(t, "", key)

evt := testutil.EventIs[domain.UserAPIKeyChanged](t, &user, 1)

testutil.Equals(t, user.ID(), evt.ID)
testutil.Equals(t, key, string(evt.Key))
})
}
22 changes: 22 additions & 0 deletions internal/auth/domain/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,19 @@ type (
ID UserID
Password PasswordHash
}

UserAPIKeyChanged struct {
bus.Notification

ID UserID
Key APIKey
}
)

func (UserRegistered) Name_() string { return "auth.event.user_registered" }
func (UserEmailChanged) Name_() string { return "auth.event.user_email_changed" }
func (UserPasswordChanged) Name_() string { return "auth.event.user_password_changed" }
func (UserAPIKeyChanged) Name_() string { return "auth.event.user_api_key_changed" }

func NewUser(emailRequirement EmailRequirement, password PasswordHash, key APIKey) (u User, err error) {
email, err := emailRequirement.Met()
Expand Down Expand Up @@ -144,6 +152,18 @@ func (u *User) HasPassword(password PasswordHash) {
})
}

// Updates the user API key
func (u *User) HasAPIKey(key APIKey) {
if u.key == key {
return
}

u.apply(UserAPIKeyChanged{
ID: u.id,
Key: key,
})
}

func (u *User) ID() UserID { return u.id }
func (u *User) Password() PasswordHash { return u.password }

Expand All @@ -159,6 +179,8 @@ func (u *User) apply(e event.Event) {
u.email = evt.Email
case UserPasswordChanged:
u.password = evt.Password
case UserAPIKeyChanged:
u.key = evt.Key
}

event.Store(u, e)
Expand Down
12 changes: 12 additions & 0 deletions internal/auth/domain/user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,16 @@ func Test_User(t *testing.T) {
testutil.Equals(t, u.ID(), evt.ID)
testutil.Equals(t, "anotherPassword", evt.Password)
})

t.Run("should be able to change API key", func(t *testing.T) {
u := must.Panic(domain.NewUser(domain.NewEmailRequirement("[email protected]", true), "someHashedPassword", "apikey"))

u.HasAPIKey("apikey") // no change, should not trigger events
u.HasAPIKey("anotherKey")

testutil.HasNEvents(t, &u, 2)
evt := testutil.EventIs[domain.UserAPIKeyChanged](t, &u, 1)
testutil.Equals(t, u.ID(), evt.ID)
testutil.Equals(t, "anotherKey", evt.Key)
})
}
8 changes: 8 additions & 0 deletions internal/auth/infra/memory/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,14 @@ func (s *usersStore) Write(ctx context.Context, users ...*domain.User) error {
key: evt.Key,
value: user,
})
case domain.UserAPIKeyChanged:
for _, u := range s.users {
if u.id == evt.ID {
u.key = evt.Key
*u.value = *user
break
}
}
default:
for _, u := range s.users {
if u.id == user.ID() {
Expand Down
2 changes: 2 additions & 0 deletions internal/auth/infra/mod.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

"github.com/YuukanOO/seelf/internal/auth/app/create_first_account"
"github.com/YuukanOO/seelf/internal/auth/app/login"
"github.com/YuukanOO/seelf/internal/auth/app/refresh_api_key"
"github.com/YuukanOO/seelf/internal/auth/app/update_user"
"github.com/YuukanOO/seelf/internal/auth/domain"
"github.com/YuukanOO/seelf/internal/auth/infra/crypto"
Expand All @@ -28,6 +29,7 @@ func Setup(
bus.Register(b, login.Handler(usersStore, passwordHasher))
bus.Register(b, create_first_account.Handler(usersStore, usersStore, passwordHasher, keyGenerator))
bus.Register(b, update_user.Handler(usersStore, usersStore, passwordHasher))
bus.Register(b, refresh_api_key.Handler(usersStore, usersStore, keyGenerator))
bus.Register(b, authQueryHandler.GetProfile)

return usersStore, db.Migrate(authsqlite.Migrations)
Expand Down
7 changes: 7 additions & 0 deletions internal/auth/infra/sqlite/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,13 @@ func (s *usersStore) Write(c context.Context, users ...*domain.User) error {
}).
F("WHERE id = ?", evt.ID).
Exec(s.db, ctx)
case domain.UserAPIKeyChanged:
return builder.
Update("users", builder.Values{
"api_key": evt.Key,
}).
F("WHERE id = ?", evt.ID).
Exec(s.db, ctx)
default:
return nil
}
Expand Down

0 comments on commit bfcec8e

Please sign in to comment.