Skip to content

Commit c763c62

Browse files
committed
feat(api,cli): user store
1 parent 92f1f26 commit c763c62

File tree

17 files changed

+464
-117
lines changed

17 files changed

+464
-117
lines changed

api/services/auth.go

+16-15
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"time"
1717

1818
"github.com/cnf/structhash"
19+
"github.com/shellhub-io/shellhub/api/store"
1920
"github.com/shellhub-io/shellhub/pkg/api/authorizer"
2021
"github.com/shellhub-io/shellhub/pkg/api/jwttoken"
2122
"github.com/shellhub-io/shellhub/pkg/api/requests"
@@ -183,19 +184,18 @@ func (s *service) AuthDevice(ctx context.Context, req requests.DeviceAuth, remot
183184
}
184185

185186
func (s *service) AuthLocalUser(ctx context.Context, req *requests.AuthLocalUser, sourceIP string) (*models.UserAuthResponse, int64, string, error) {
186-
if s, err := s.store.SystemGet(ctx); err != nil || !s.Authentication.Local.Enabled {
187-
return nil, 0, "", NewErrAuthMethodNotAllowed(models.UserAuthMethodLocal.String())
188-
}
189-
190-
var err error
191-
var user *models.User
187+
// if s, err := s.store.SystemGet(ctx); err != nil || !s.Authentication.Local.Enabled {
188+
// return nil, 0, "", NewErrAuthMethodNotAllowed(models.UserAuthMethodLocal.String())
189+
// }
192190

191+
var ident store.UserIdent
193192
if req.Identifier.IsEmail() {
194-
user, err = s.store.UserGetByEmail(ctx, strings.ToLower(string(req.Identifier)))
193+
ident = store.UserIdentEmail
195194
} else {
196-
user, err = s.store.UserGetByUsername(ctx, strings.ToLower(string(req.Identifier)))
195+
ident = store.UserIdentUsername
197196
}
198197

198+
user, err := s.store.UserGet(ctx, ident, strings.ToLower(string(req.Identifier)))
199199
if err != nil {
200200
return nil, 0, "", NewErrAuthUnathorized(nil)
201201
}
@@ -285,15 +285,15 @@ func (s *service) AuthLocalUser(ctx context.Context, req *requests.AuthLocalUser
285285
}
286286

287287
// Updates last_login and the hash algorithm to bcrypt if still using SHA256
288-
changes := &models.UserChanges{LastLogin: clock.Now(), PreferredNamespace: &tenantID}
288+
user.LastLogin = clock.Now()
289+
user.Preferences.PreferredNamespace = tenantID
289290
if !strings.HasPrefix(user.PasswordDigest, "$") {
290-
if pwdDigest, _ := hash.Do(req.Password); pwdDigest != "" {
291-
changes.Password = pwdDigest
292-
}
291+
pwdDigest, _ := hash.Do(req.Password)
292+
user.PasswordDigest = pwdDigest
293293
}
294294

295295
// TODO: evaluate make this update in a go routine.
296-
if err := s.store.UserUpdate(ctx, user.ID, changes); err != nil {
296+
if err := s.store.UserSave(ctx, user); err != nil {
297297
return nil, 0, "", NewErrUserUpdate(user, err)
298298
}
299299

@@ -322,7 +322,7 @@ func (s *service) AuthLocalUser(ctx context.Context, req *requests.AuthLocalUser
322322
}
323323

324324
func (s *service) CreateUserToken(ctx context.Context, req *requests.CreateUserToken) (*models.UserAuthResponse, error) {
325-
user, _, err := s.store.UserGetByID(ctx, req.UserID, false)
325+
user, err := s.store.UserGet(ctx, store.UserIdentID, req.UserID)
326326
if err != nil {
327327
return nil, NewErrUserNotFound(req.UserID, err)
328328
}
@@ -366,7 +366,8 @@ func (s *service) CreateUserToken(ctx context.Context, req *requests.CreateUserT
366366
role = member.Role.String()
367367

368368
if user.Preferences.PreferredNamespace != namespace.TenantID {
369-
_ = s.store.UserUpdate(ctx, user.ID, &models.UserChanges{PreferredNamespace: &tenantID})
369+
user.Preferences.PreferredNamespace = namespace.TenantID
370+
_ = s.store.Save(ctx, user)
370371
}
371372
}
372373

api/services/member.go

+24-17
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ func (s *service) AddNamespaceMember(ctx context.Context, req *requests.Namespac
5353
return nil, NewErrNamespaceNotFound(req.TenantID, err)
5454
}
5555

56-
user, _, err := s.store.UserGetByID(ctx, req.UserID, false)
56+
user, err := s.store.UserGet(ctx, store.UserIdentID, req.UserID)
5757
if err != nil || user == nil {
5858
return nil, NewErrUserNotFound(req.UserID, err)
5959
}
@@ -71,17 +71,18 @@ func (s *service) AddNamespaceMember(ctx context.Context, req *requests.Namespac
7171
// In cloud instances, if the target user does not exist, we need to create a new user
7272
// with the specified email. We use the inserted ID to identify the user once they complete
7373
// the registration and accepts the invitation.
74-
passiveUser, err := s.store.UserGetByEmail(ctx, strings.ToLower(req.MemberEmail))
74+
passiveUser, err := s.store.UserGet(ctx, store.UserIdentEmail, strings.ToLower(req.MemberEmail))
7575
if err != nil {
76-
if !envs.IsCloud() || !errors.Is(err, store.ErrNoDocuments) {
77-
return nil, NewErrUserNotFound(req.MemberEmail, err)
78-
}
79-
80-
passiveUser = &models.User{}
81-
passiveUser.ID, err = s.store.UserCreateInvited(ctx, strings.ToLower(req.MemberEmail))
82-
if err != nil {
83-
return nil, err
84-
}
76+
return nil, NewErrUserNotFound(req.MemberEmail, err)
77+
// if !envs.IsCloud() || !errors.Is(err, store.ErrNoDocuments) {
78+
// return nil, NewErrUserNotFound(req.MemberEmail, err)
79+
// }
80+
81+
// passiveUser = &models.User{}
82+
// passiveUser.ID, err = s.store.UserCreateInvited(ctx, strings.ToLower(req.MemberEmail))
83+
// if err != nil {
84+
// return nil, err
85+
// }
8586
}
8687

8788
// In cloud instances, if a member exists and their status is pending and the expiration date is reached,
@@ -161,7 +162,7 @@ func (s *service) UpdateNamespaceMember(ctx context.Context, req *requests.Names
161162
return NewErrNamespaceNotFound(req.TenantID, err)
162163
}
163164

164-
user, _, err := s.store.UserGetByID(ctx, req.UserID, false)
165+
user, err := s.store.UserGet(ctx, store.UserIdentID, req.UserID)
165166
if err != nil {
166167
return NewErrUserNotFound(req.UserID, err)
167168
}
@@ -198,7 +199,7 @@ func (s *service) RemoveNamespaceMember(ctx context.Context, req *requests.Names
198199
return nil, NewErrNamespaceNotFound(req.TenantID, err)
199200
}
200201

201-
user, _, err := s.store.UserGetByID(ctx, req.UserID, false)
202+
user, err := s.store.UserGet(ctx, store.UserIdentID, req.UserID)
202203
if err != nil {
203204
return nil, NewErrUserNotFound(req.UserID, err)
204205
}
@@ -237,11 +238,17 @@ func (s *service) LeaveNamespace(ctx context.Context, req *requests.LeaveNamespa
237238
return nil, NewErrNamespaceNotFound(req.TenantID, err)
238239
}
239240

240-
if m, ok := ns.FindMember(req.UserID); !ok || m.Role == authorizer.RoleOwner {
241+
user, err := s.store.UserGet(ctx, store.UserIdentID, req.UserID)
242+
if err != nil || user == nil {
243+
return nil, NewErrUserNotFound(req.UserID, err)
244+
}
245+
246+
member, ok := ns.FindMember(user.ID)
247+
if !ok || member.Role == authorizer.RoleOwner {
241248
return nil, NewErrAuthForbidden()
242249
}
243250

244-
if err := s.removeMember(ctx, ns, req.UserID); err != nil { //nolint:revive
251+
if err := s.removeMember(ctx, ns, user.ID); err != nil { //nolint:revive
245252
return nil, err
246253
}
247254

@@ -251,8 +258,8 @@ func (s *service) LeaveNamespace(ctx context.Context, req *requests.LeaveNamespa
251258
return nil, nil
252259
}
253260

254-
emptyString := "" // just to be used as a pointer
255-
if err := s.store.UserUpdate(ctx, req.UserID, &models.UserChanges{PreferredNamespace: &emptyString}); err != nil {
261+
user.Preferences.PreferredNamespace = ""
262+
if err := s.store.Save(ctx, user); err != nil {
256263
log.WithError(err).
257264
WithField("tenant_id", req.TenantID).
258265
WithField("user_id", req.UserID).

api/services/namespace.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ type NamespaceService interface {
2525

2626
// CreateNamespace creates a new namespace.
2727
func (s *service) CreateNamespace(ctx context.Context, req *requests.NamespaceCreate) (*models.Namespace, error) {
28-
user, _, err := s.store.UserGetByID(ctx, req.UserID, false)
28+
user, err := s.store.UserGet(ctx, store.UserIdentID, req.UserID)
2929
if err != nil || user == nil {
3030
return nil, NewErrUserNotFound(req.UserID, err)
3131
}

api/services/setup.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ func (s *service) Setup(ctx context.Context, req requests.Setup) error {
8383
}
8484

8585
if _, err = s.store.NamespaceCreate(ctx, namespace); err != nil {
86-
if err := s.store.UserDelete(ctx, insertedID); err != nil {
86+
if err := s.store.Delete(ctx, insertedID); err != nil {
8787
return NewErrUserDelete(err)
8888
}
8989

api/services/user.go

+24-10
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@ import (
44
"context"
55
"strings"
66

7+
"github.com/shellhub-io/shellhub/api/store"
78
"github.com/shellhub-io/shellhub/pkg/api/requests"
89
"github.com/shellhub-io/shellhub/pkg/hash"
910
"github.com/shellhub-io/shellhub/pkg/models"
11+
"golang.org/x/text/cases"
12+
"golang.org/x/text/language"
1013
)
1114

1215
type UserService interface {
@@ -23,7 +26,7 @@ type UserService interface {
2326
}
2427

2528
func (s *service) UpdateUser(ctx context.Context, req *requests.UpdateUser) ([]string, error) {
26-
user, _, err := s.store.UserGetByID(ctx, req.UserID, false)
29+
user, err := s.store.UserGet(ctx, store.UserIdentID, req.UserID)
2730
if err != nil {
2831
return []string{}, NewErrUserNotFound(req.UserID, nil)
2932
}
@@ -38,11 +41,20 @@ func (s *service) UpdateUser(ctx context.Context, req *requests.UpdateUser) ([]s
3841
return conflicts, NewErrUserDuplicated(conflicts, nil)
3942
}
4043

41-
changes := &models.UserChanges{
42-
Name: req.Name,
43-
Username: strings.ToLower(req.Username),
44-
Email: strings.ToLower(req.Email),
45-
RecoveryEmail: strings.ToLower(req.RecoveryEmail),
44+
if req.Name != "" {
45+
user.Name = cases.Title(language.AmericanEnglish).String(strings.ToLower(req.Name))
46+
}
47+
48+
if req.Username != "" {
49+
user.Username = strings.ToLower(req.Username)
50+
}
51+
52+
if req.Email != "" {
53+
user.Email = strings.ToLower(req.Email)
54+
}
55+
56+
if req.RecoveryEmail != "" {
57+
user.Preferences.RecoveryEmail = strings.ToLower(req.RecoveryEmail)
4658
}
4759

4860
if req.Password != "" {
@@ -52,10 +64,10 @@ func (s *service) UpdateUser(ctx context.Context, req *requests.UpdateUser) ([]s
5264
}
5365

5466
pwdDigest, _ := hash.Do(req.Password)
55-
changes.Password = pwdDigest
67+
user.PasswordDigest = pwdDigest
5668
}
5769

58-
if err := s.store.UserUpdate(ctx, req.UserID, changes); err != nil {
70+
if err := s.store.UserSave(ctx, user); err != nil {
5971
return []string{}, NewErrUserUpdate(user, err)
6072
}
6173

@@ -66,7 +78,7 @@ func (s *service) UpdateUser(ctx context.Context, req *requests.UpdateUser) ([]s
6678
//
6779
// Deprecated, use [Service.UpdateUser] instead.
6880
func (s *service) UpdatePasswordUser(ctx context.Context, id, currentPassword, newPassword string) error {
69-
user, _, err := s.store.UserGetByID(ctx, id, false)
81+
user, err := s.store.UserGet(ctx, store.UserIdentID, id)
7082
if user == nil {
7183
return NewErrUserNotFound(id, err)
7284
}
@@ -80,7 +92,9 @@ func (s *service) UpdatePasswordUser(ctx context.Context, id, currentPassword, n
8092
return NewErrUserPasswordInvalid(err)
8193
}
8294

83-
if err := s.store.UserUpdate(ctx, id, &models.UserChanges{Password: pwdDigest}); err != nil {
95+
user.PasswordDigest = pwdDigest
96+
97+
if err := s.store.UserSave(ctx, user); err != nil {
8498
return NewErrUserUpdate(user, err)
8599
}
86100

api/store/pg/internal/dbtest/fixtures/.keep

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
- model: User
2+
rows:
3+
- id: 0195cefa-aa01-7efb-8098-c9c173056250
4+
created_at: 2025-01-15T10:30:00+00:00
5+
updated_at: 2025-01-15T10:30:00+00:00
6+
last_login: null
7+
status: confirmed
8+
origin: local
9+
external_id: ""
10+
name: Jonh Doe
11+
username: john_doe
12+
13+
security_email: [email protected]
14+
password_digest: "$2y$12$VVm2ETx7AvaGlfMYqNYK9uzU2M45YZ70YnT..O.s1o2zdE1pekhq6"
15+
auth_methods: [ local ]
16+
namespace_ownership_limit: -1
17+
email_marketing: true
18+
preferred_namespace_id: null

api/store/pg/internal/entity/user.go

+106
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package entity
2+
3+
import (
4+
"time"
5+
6+
"github.com/shellhub-io/shellhub/pkg/models"
7+
"github.com/uptrace/bun"
8+
)
9+
10+
type User struct {
11+
bun.BaseModel `bun:"table:users"`
12+
13+
ID string `bun:"id,pk,type:uuid"`
14+
CreatedAt time.Time `bun:"created_at"`
15+
UpdatedAt time.Time `bun:"updated_at"`
16+
LastLogin time.Time `bun:"last_login,nullzero"`
17+
Origin string `bun:"origin"`
18+
ExternalID string `bun:"external_id,nullzero"`
19+
Status string `bun:"status"`
20+
Name string `bun:"name"`
21+
Username string `bun:"username"`
22+
Email string `bun:"email"`
23+
PasswordDigest string `bun:"password_digest"`
24+
Preferences UserPreferences `bun:"embed:"`
25+
MFA UserMFA `bun:"-"`
26+
}
27+
28+
type UserPreferences struct {
29+
PreferredNamespace string `bun:"preferred_namespace_id,nullzero"`
30+
AuthMethods []string `bun:"auth_methods,array"`
31+
SecurityEmail string `bun:"security_email,nullzero"`
32+
MaxNamespaces int `bun:"namespace_ownership_limit"`
33+
EmailMarketing bool `bun:"email_marketing"`
34+
}
35+
36+
type UserMFA struct {
37+
Enabled bool `bun:"enabled"`
38+
Secret string `bun:"secret,nullzero"`
39+
RecoveryCodes []string `bun:"recovery_codes,nullzero,array"`
40+
}
41+
42+
func UserFromModel(model *models.User) *User {
43+
authMethods := make([]string, len(model.Preferences.AuthMethods))
44+
for i, method := range model.Preferences.AuthMethods {
45+
authMethods[i] = method.String()
46+
}
47+
48+
return &User{
49+
ID: model.ID,
50+
CreatedAt: model.CreatedAt,
51+
UpdatedAt: model.UpdatedAt,
52+
LastLogin: model.LastLogin,
53+
Origin: model.Origin.String(),
54+
ExternalID: model.ExternalID,
55+
Status: model.Status.String(),
56+
Name: model.Name,
57+
Username: model.Username,
58+
Email: model.Email,
59+
PasswordDigest: model.PasswordDigest,
60+
Preferences: UserPreferences{
61+
PreferredNamespace: model.Preferences.PreferredNamespace,
62+
AuthMethods: authMethods,
63+
SecurityEmail: model.Preferences.RecoveryEmail,
64+
MaxNamespaces: model.MaxNamespaces,
65+
EmailMarketing: model.EmailMarketing,
66+
},
67+
MFA: UserMFA{
68+
Enabled: model.MFA.Enabled,
69+
Secret: model.MFA.Secret,
70+
RecoveryCodes: model.MFA.RecoveryCodes,
71+
},
72+
}
73+
}
74+
75+
func UserToModel(entity *User) *models.User {
76+
authMethods := make([]models.UserAuthMethod, len(entity.Preferences.AuthMethods))
77+
for i, method := range entity.Preferences.AuthMethods {
78+
authMethods[i] = models.UserAuthMethod(method)
79+
}
80+
81+
return &models.User{
82+
ID: entity.ID,
83+
Origin: models.UserOrigin(entity.Origin),
84+
ExternalID: entity.ExternalID,
85+
Status: models.UserStatus(entity.Status),
86+
MaxNamespaces: entity.Preferences.MaxNamespaces,
87+
CreatedAt: entity.CreatedAt,
88+
UpdatedAt: entity.UpdatedAt,
89+
LastLogin: entity.LastLogin,
90+
EmailMarketing: entity.Preferences.EmailMarketing,
91+
Name: entity.Name,
92+
Username: entity.Username,
93+
Email: entity.Email,
94+
PasswordDigest: entity.PasswordDigest,
95+
MFA: models.UserMFA{
96+
Enabled: entity.MFA.Enabled,
97+
Secret: entity.MFA.Secret,
98+
RecoveryCodes: entity.MFA.RecoveryCodes,
99+
},
100+
Preferences: models.UserPreferences{
101+
PreferredNamespace: entity.Preferences.PreferredNamespace,
102+
AuthMethods: authMethods,
103+
RecoveryEmail: entity.Preferences.SecurityEmail,
104+
},
105+
}
106+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
BEGIN;
2+
3+
DROP TYPE IF EXISTS user_origin;
4+
DROP TYPE IF EXISTS user_status;
5+
DROP TYPE IF EXISTS user_auth_method;
6+
DROP TABLE IF EXISTS users;
7+
8+
COMMIT;

0 commit comments

Comments
 (0)