profileShare

rasmusjy / tasteprint

Read-only snapshot

No repository description.

main default branch 169 files Expires Sep 13, 2026, 9:06 AM
AccountService.java 8,458 bytes
1 package com.tasteprint.account;
2
3 import java.nio.charset.StandardCharsets;
4 import java.security.MessageDigest;
5 import java.security.NoSuchAlgorithmException;
6 import java.security.SecureRandom;
7 import java.time.Clock;
8 import java.time.Duration;
9 import java.time.Instant;
10 import java.util.Base64;
11 import java.util.HexFormat;
12 import java.util.Locale;
13 import java.util.UUID;
14
15 import org.springframework.security.authentication.BadCredentialsException;
16 import org.springframework.security.crypto.password.PasswordEncoder;
17 import org.springframework.context.ApplicationEventPublisher;
18 import org.springframework.stereotype.Service;
19 import org.springframework.transaction.annotation.Transactional;
20
21 import com.tasteprint.shared.ConflictException;
22 import com.tasteprint.shared.ForbiddenException;
23 import com.tasteprint.shared.NotFoundException;
24
25 @Service
26 public class AccountService {
27
28 private static final Duration SESSION_LIFETIME = Duration.ofDays(30);
29 private static final SecureRandom SECURE_RANDOM = new SecureRandom();
30
31 private final AccountRepository accounts;
32 private final SessionTokenRepository sessions;
33 private final PasswordEncoder passwordEncoder;
34 private final ApplicationEventPublisher events;
35 private final Clock clock;
36
37 AccountService(AccountRepository accounts, SessionTokenRepository sessions,
38 PasswordEncoder passwordEncoder, ApplicationEventPublisher events, Clock clock) {
39 this.accounts = accounts;
40 this.sessions = sessions;
41 this.passwordEncoder = passwordEncoder;
42 this.events = events;
43 this.clock = clock;
44 }
45
46 @Transactional
47 public AuthSession register(RegisterRequest request) {
48 String email = request.email().trim().toLowerCase(Locale.ROOT);
49 if (accounts.findByEmailIgnoreCase(email).isPresent()) {
50 throw new ConflictException("An account with that email already exists.");
51 }
52
53 Instant now = clock.instant();
54 Account account = new Account(
55 UUID.randomUUID(),
56 request.displayName(),
57 email,
58 passwordEncoder.encode(request.password()),
59 uniqueShareSlug(request.displayName()),
60 now
61 );
62 accounts.save(account);
63 return newSession(account, now);
64 }
65
66 @Transactional
67 public AuthSession login(LoginRequest request) {
68 Account account = accounts.findByEmailIgnoreCase(request.email().trim())
69 .orElseThrow(() -> new BadCredentialsException("Invalid email or password."));
70 if (!passwordEncoder.matches(request.password(), account.passwordHash())) {
71 throw new BadCredentialsException("Invalid email or password.");
72 }
73 Instant now = clock.instant();
74 sessions.deleteByExpiresAtBefore(now);
75 return newSession(account, now);
76 }
77
78 @Transactional
79 public void logout(String rawToken) {
80 if (rawToken != null && !rawToken.isBlank()) {
81 sessions.deleteByTokenHash(hash(rawToken));
82 }
83 }
84
85 @Transactional(readOnly = true)
86 public AuthenticatedUser authenticate(String rawToken) {
87 SessionToken session = sessions.findByTokenHashAndExpiresAtAfter(hash(rawToken), clock.instant())
88 .orElseThrow(() -> new BadCredentialsException("Session is invalid or expired."));
89 Account account = requiredAccount(session.userId());
90 return new AuthenticatedUser(account.id(), account.email(), account.displayName(), account.shareSlug());
91 }
92
93 @Transactional(readOnly = true)
94 public AccountView get(UUID accountId) {
95 return view(requiredAccount(accountId));
96 }
97
98 @Transactional(readOnly = true)
99 public PublicAccountView getPublic(String shareSlug) {
100 Account account = accounts.findByShareSlug(shareSlug)
101 .orElseThrow(() -> new NotFoundException("Tasteprint profile was not found."));
102 if (!account.profilePublic()) {
103 throw new NotFoundException("Tasteprint profile was not found.");
104 }
105 return publicView(account);
106 }
107
108 @Transactional(readOnly = true)
109 public PublicAccountView getPublicById(UUID accountId) {
110 Account account = requiredAccount(accountId);
111 return publicView(account);
112 }
113
114 @Transactional
115 public AccountView update(UUID accountId, UpdateProfileRequest request) {
116 Account account = requiredAccount(accountId);
117 account.updateProfile(
118 request.displayName(), request.homeCity(), request.homeCountryCode(), request.bio(),
119 request.avatarUrl(), request.profilePublic(), clock.instant()
120 );
121 return view(account);
122 }
123
124 @Transactional
125 public void delete(UUID accountId, String password) {
126 Account account = requiredAccount(accountId);
127 if (!passwordEncoder.matches(password, account.passwordHash())) {
128 throw new BadCredentialsException("Invalid password.");
129 }
130 events.publishEvent(new AccountDeletionRequested(accountId));
131 accounts.delete(account);
132 }
133
134 @Transactional
135 public UUID ensureDemoAccount(String displayName, String email, String password, boolean publicProfile) {
136 Account account = accounts.findByEmailIgnoreCase(email).orElseGet(() -> {
137 Instant now = clock.instant();
138 return accounts.save(new Account(
139 UUID.randomUUID(), displayName, email, passwordEncoder.encode(password),
140 uniqueShareSlug(displayName), now
141 ));
142 });
143 if (publicProfile && !account.profilePublic()) {
144 account.makePublic(clock.instant());
145 }
146 return account.id();
147 }
148
149 @Transactional(readOnly = true)
150 public void requireSameUser(UUID authenticatedUserId, UUID requestedUserId) {
151 if (!authenticatedUserId.equals(requestedUserId)) {
152 throw new ForbiddenException("You cannot access another account's private data.");
153 }
154 }
155
156 private AuthSession newSession(Account account, Instant now) {
157 byte[] bytes = new byte[32];
158 SECURE_RANDOM.nextBytes(bytes);
159 String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
160 sessions.save(new SessionToken(
161 UUID.randomUUID(), account.id(), hash(token), now.plus(SESSION_LIFETIME), now
162 ));
163 return new AuthSession(token, view(account));
164 }
165
166 private String uniqueShareSlug(String displayName) {
167 String base = displayName.toLowerCase(Locale.ROOT)
168 .replaceAll("[^a-z0-9]+", "-")
169 .replaceAll("(^-|-$)", "");
170 if (base.isBlank()) {
171 base = "traveller";
172 }
173 base = base.substring(0, Math.min(base.length(), 70));
174
175 for (int attempt = 0; attempt < 20; attempt++) {
176 String suffix = UUID.randomUUID().toString().replace("-", "").substring(0, 4);
177 String candidate = base + "-" + suffix;
178 if (!accounts.existsByShareSlug(candidate)) {
179 return candidate;
180 }
181 }
182 return base + "-" + UUID.randomUUID().toString().substring(0, 8);
183 }
184
185 private Account requiredAccount(UUID accountId) {
186 return accounts.findById(accountId)
187 .orElseThrow(() -> new NotFoundException("Account was not found."));
188 }
189
190 private AccountView view(Account account) {
191 return new AccountView(
192 account.id(), account.displayName(), account.email(), account.shareSlug(),
193 account.homeCity(), account.homeCountryCode(), account.bio(), account.avatarUrl(),
194 account.profilePublic(), account.createdAt()
195 );
196 }
197
198 private PublicAccountView publicView(Account account) {
199 return new PublicAccountView(
200 account.id(), account.displayName(), account.shareSlug(), account.homeCity(),
201 account.homeCountryCode(), account.bio(), account.avatarUrl(), account.createdAt()
202 );
203 }
204
205 private String hash(String value) {
206 try {
207 byte[] digest = MessageDigest.getInstance("SHA-256")
208 .digest(value.getBytes(StandardCharsets.UTF_8));
209 return HexFormat.of().formatHex(digest);
210 } catch (NoSuchAlgorithmException exception) {
211 throw new IllegalStateException("SHA-256 is unavailable.", exception);
212 }
213 }
214 }
215