all repos

onasty @ d87c770

a one-time notes service

onasty/internal/service/authsrv/authsrv.go (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
fix: don't return "wrong credentials" (#201), 9 months ago
1
package authsrv
2
3
import (
4
	"context"
5
	"errors"
6
	"log/slog"
7
	"time"
8
9
	"github.com/gofrs/uuid/v5"
10
	"github.com/olexsmir/onasty/internal/dtos"
11
	"github.com/olexsmir/onasty/internal/events/mailermq"
12
	"github.com/olexsmir/onasty/internal/hasher"
13
	"github.com/olexsmir/onasty/internal/jwtutil"
14
	"github.com/olexsmir/onasty/internal/models"
15
	"github.com/olexsmir/onasty/internal/oauth"
16
	"github.com/olexsmir/onasty/internal/store/psql/sessionrepo"
17
	"github.com/olexsmir/onasty/internal/store/psql/userepo"
18
	"github.com/olexsmir/onasty/internal/store/psql/vertokrepo"
19
	"github.com/olexsmir/onasty/internal/store/rdb/usercache"
20
)
21
22
type AuthServicer interface {
23
	// SignUp creates a new user and sends a verification email.
24
	//
25
	// Uses [models.User.Validate] to validate credentials (see more possible returned errors).
26
	//
27
	// If provided email already in use returns [models.ErrUserEmailIsAlreadyInUse].
28
	//
29
	SignUp(ctx context.Context, credentials dtos.SignUp) error
30
31
	// SignIn authenticates a user and returns access and refresh tokens.
32
	//
33
	// If user not found returns [models.ErrUserNotFound], and if credentials don't match [models.ErrUserWrongCredentials]
34
	//
35
	// If inactivated user tries to login, returns [models.ErrUserIsNotActivated]
36
	//
37
	SignIn(ctx context.Context, credentials dtos.SignIn) (dtos.Tokens, error)
38
39
	// RefreshTokens refreshes the access and refresh tokens using the provided refresh token.
40
	//
41
	// If couldn't find a user liked with token, returns [models.ErrUserNotFound]
42
	//
43
	RefreshTokens(ctx context.Context, refreshToken string) (dtos.Tokens, error)
44
45
	// Logout logs out a user by deleting the session associated with the provided refresh token.
46
	Logout(ctx context.Context, userID uuid.UUID, refreshToken string) error
47
48
	// LogoutAll logs out a user by deleting all sessions associated with the user ID.
49
	LogoutAll(ctx context.Context, userID uuid.UUID) error
50
51
	// GetOAuthURL retrieves the OAuth URL for the specified provider.
52
	//
53
	// If [providerName] is incorrect returns [ErrProviderNotSupported]
54
	//
55
	GetOAuthURL(providerName string) (dtos.OAuthRedirect, error)
56
57
	// HandleOAuthLogin handles the OAuth login process by exchanging the code for tokens.
58
	//
59
	HandleOAuthLogin(ctx context.Context, providerName, code string) (dtos.Tokens, error)
60
61
	// ParseJWTToken parses the JWT token and returns the payload.
62
	//
63
	// If token is expired, returns [jwtutil.ErrTokenExpired],
64
	//
65
	// If token is invalid returns: [jwturil.ErrTokenSignatureInvalid], [jwt.ErrUnexpectedSigningMethod]
66
	//
67
	ParseJWTToken(token string) (jwtutil.Payload, error)
68
69
	// CheckIfUserExists checks if a user exists by user ID.
70
	CheckIfUserExists(ctx context.Context, userID uuid.UUID) (bool, error)
71
72
	// CheckIfUserIsActivated checks if a user is activated by user ID.
73
	CheckIfUserIsActivated(ctx context.Context, userID uuid.UUID) (bool, error)
74
}
75
76
var _ AuthServicer = (*AuthSrv)(nil)
77
78
type AuthSrv struct {
79
	userstore    userepo.UserStorer
80
	sessionstore sessionrepo.SessionStorer
81
	vertokrepo   vertokrepo.VerificationTokenStorer
82
	cache        usercache.UserCacheer
83
84
	hasher       hasher.Hasher
85
	jwtTokenizer jwtutil.JWTTokenizer
86
	mailermq     mailermq.Mailer
87
88
	googleOauth oauth.Provider
89
	githubOauth oauth.Provider
90
91
	refreshTokenTTL      time.Duration
92
	verificationTokenTTL time.Duration
93
}
94
95
func New(
96
	userstore userepo.UserStorer,
97
	sessionstore sessionrepo.SessionStorer,
98
	vertokrepo vertokrepo.VerificationTokenStorer,
99
	cache usercache.UserCacheer,
100
	hasher hasher.Hasher,
101
	jwtTokenizer jwtutil.JWTTokenizer,
102
	mailermq mailermq.Mailer,
103
	googleOauth, githubOauth oauth.Provider,
104
	refreshTokenTTL, verificationTokenTTL time.Duration,
105
) *AuthSrv {
106
	return &AuthSrv{
107
		userstore:            userstore,
108
		sessionstore:         sessionstore,
109
		vertokrepo:           vertokrepo,
110
		cache:                cache,
111
		hasher:               hasher,
112
		jwtTokenizer:         jwtTokenizer,
113
		mailermq:             mailermq,
114
		googleOauth:          googleOauth,
115
		githubOauth:          githubOauth,
116
		refreshTokenTTL:      refreshTokenTTL,
117
		verificationTokenTTL: verificationTokenTTL,
118
	}
119
}
120
121
func (a *AuthSrv) SignUp(ctx context.Context, inp dtos.SignUp) error {
122
	user := models.User{
123
		ID:          uuid.Nil, // nil, since we do not know it yet
124
		Email:       inp.Email,
125
		Activated:   false,
126
		Password:    inp.Password,
127
		CreatedAt:   inp.CreatedAt,
128
		LastLoginAt: inp.LastLoginAt,
129
	}
130
	if err := user.Validate(); err != nil {
131
		return err
132
	}
133
134
	hashedPassword, err := a.hasher.Hash(inp.Password)
135
	if err != nil {
136
		return err
137
	}
138
139
	user.Password = hashedPassword
140
141
	userID, err := a.userstore.Create(ctx, user)
142
	if err != nil {
143
		return err
144
	}
145
146
	verificationToken := uuid.Must(uuid.NewV4()).String()
147
	if err := a.vertokrepo.Create(ctx, models.VerificationToken{
148
		UserID:    userID,
149
		Token:     verificationToken,
150
		CreatedAt: time.Now(),
151
		ExpiresAt: time.Now().Add(a.verificationTokenTTL),
152
	}); err != nil {
153
		return err
154
	}
155
156
	if err := a.mailermq.SendVerificationEmail(ctx, mailermq.SendVerificationEmailRequest{
157
		Receiver: inp.Email,
158
		Token:    verificationToken,
159
	}); err != nil {
160
		return err
161
	}
162
163
	return nil
164
}
165
166
func (a *AuthSrv) SignIn(ctx context.Context, inp dtos.SignIn) (dtos.Tokens, error) {
167
	user, err := a.userstore.GetByEmail(ctx, inp.Email)
168
	if err != nil {
169
		return dtos.Tokens{}, err
170
	}
171
172
	if err = a.hasher.Compare(user.Password, inp.Password); err != nil {
173
		if errors.Is(err, hasher.ErrMismatchedHashes) {
174
			return dtos.Tokens{}, models.ErrUserNotFound
175
		}
176
		return dtos.Tokens{}, err
177
	}
178
179
	if !user.IsActivated() {
180
		return dtos.Tokens{}, models.ErrUserIsNotActivated
181
	}
182
183
	return a.issueTokens(ctx, user.ID)
184
}
185
186
func (a *AuthSrv) RefreshTokens(ctx context.Context, rtoken string) (dtos.Tokens, error) {
187
	userID, err := a.sessionstore.GetUserIDByRefreshToken(ctx, rtoken)
188
	if err != nil {
189
		return dtos.Tokens{}, err
190
	}
191
192
	tokens, err := a.createTokens(userID)
193
	if err != nil {
194
		return dtos.Tokens{}, err
195
	}
196
197
	if err := a.sessionstore.Update(ctx, userID, rtoken, tokens.Refresh); err != nil {
198
		return dtos.Tokens{}, err
199
	}
200
201
	return dtos.Tokens{
202
		Access:  tokens.Access,
203
		Refresh: tokens.Refresh,
204
	}, nil
205
}
206
207
func (a *AuthSrv) Logout(ctx context.Context, userID uuid.UUID, refreshToken string) error {
208
	return a.sessionstore.Delete(ctx, userID, refreshToken)
209
}
210
211
func (a *AuthSrv) LogoutAll(ctx context.Context, userID uuid.UUID) error {
212
	return a.sessionstore.DeleteAllByUserID(ctx, userID)
213
}
214
215
func (a *AuthSrv) CheckIfUserExists(ctx context.Context, uid uuid.UUID) (bool, error) {
216
	isExists, err := a.cache.GetIsExists(ctx, uid.String())
217
	if err == nil {
218
		return isExists, nil
219
	}
220
	slog.ErrorContext(ctx, "failed to fetch 'is user exists' cache", "err", err)
221
222
	isExists, err = a.userstore.CheckIfUserExists(ctx, uid)
223
	if err != nil {
224
		return false, err
225
	}
226
227
	if err := a.cache.SetIsExists(ctx, uid.String(), isExists); err != nil {
228
		slog.ErrorContext(ctx, "failed to update 'is user exists' cache", "err", err)
229
	}
230
231
	return isExists, nil
232
}
233
234
func (a *AuthSrv) CheckIfUserIsActivated(ctx context.Context, uid uuid.UUID) (bool, error) {
235
	isActivated, err := a.cache.GetIsActivated(ctx, uid.String())
236
	if err == nil {
237
		return isActivated, nil
238
	}
239
	slog.ErrorContext(ctx, "failed to fetch 'is user activated' cache", "err", err)
240
241
	isActivated, err = a.userstore.CheckIfUserIsActivated(ctx, uid)
242
	if err != nil {
243
		return false, err
244
	}
245
246
	if err := a.cache.SetIsActivated(ctx, uid.String(), isActivated); err != nil {
247
		slog.ErrorContext(ctx, "failed to update 'is user activated' cache", "err", err)
248
	}
249
250
	return isActivated, nil
251
}