feat: enable Registration through invitation codes
This commit is contained in:
parent
a74a6a3741
commit
2e7033791a
@ -55,7 +55,7 @@ public class SecurityConfig {
|
|||||||
.csrf(csrf -> csrf.disable())
|
.csrf(csrf -> csrf.disable())
|
||||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/", "/login", "/swagger-ui/**", "/v3/api-docs/**", "/docs/**", "/docs").permitAll()
|
.requestMatchers("/", "/login", "/checkInvitation", "/register", "/swagger-ui/**", "/v3/api-docs/**", "/docs/**", "/docs").permitAll()
|
||||||
.requestMatchers("/admin/**").hasRole("ADMIN")
|
.requestMatchers("/admin/**").hasRole("ADMIN")
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
)
|
)
|
||||||
|
|||||||
@ -8,9 +8,14 @@ import online.wesal.wesal.dto.LoginRequest;
|
|||||||
import online.wesal.wesal.dto.LoginResponse;
|
import online.wesal.wesal.dto.LoginResponse;
|
||||||
import online.wesal.wesal.dto.UpdateUserRequest;
|
import online.wesal.wesal.dto.UpdateUserRequest;
|
||||||
import online.wesal.wesal.dto.UsernameSelectionRequest;
|
import online.wesal.wesal.dto.UsernameSelectionRequest;
|
||||||
|
import online.wesal.wesal.dto.CheckInvitationRequest;
|
||||||
|
import online.wesal.wesal.dto.RegisterRequest;
|
||||||
|
import online.wesal.wesal.dto.RegisterResponse;
|
||||||
import online.wesal.wesal.entity.User;
|
import online.wesal.wesal.entity.User;
|
||||||
import online.wesal.wesal.service.AuthService;
|
import online.wesal.wesal.service.AuthService;
|
||||||
import online.wesal.wesal.service.UserService;
|
import online.wesal.wesal.service.UserService;
|
||||||
|
import online.wesal.wesal.service.InvitationCodeService;
|
||||||
|
import online.wesal.wesal.config.JwtUtil;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
@ -29,6 +34,12 @@ public class AuthController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private UserService userService;
|
private UserService userService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private InvitationCodeService invitationCodeService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JwtUtil jwtUtil;
|
||||||
|
|
||||||
@GetMapping("/")
|
@GetMapping("/")
|
||||||
@Operation(summary = "Health check", description = "Returns API status")
|
@Operation(summary = "Health check", description = "Returns API status")
|
||||||
public ResponseEntity<Map<String, Integer>> healthCheck() {
|
public ResponseEntity<Map<String, Integer>> healthCheck() {
|
||||||
@ -107,6 +118,70 @@ public class AuthController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/checkInvitation")
|
||||||
|
@Operation(summary = "Check invitation code", description = "Validates if an invitation code is valid and not expired")
|
||||||
|
public ResponseEntity<Map<String, Object>> checkInvitation(@Valid @RequestBody CheckInvitationRequest request) {
|
||||||
|
try {
|
||||||
|
boolean isValid = invitationCodeService.isValidCode(request.getCode());
|
||||||
|
if (isValid) {
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"status", true,
|
||||||
|
"message", "Invitation code is valid",
|
||||||
|
"data", Map.of("code", request.getCode(), "valid", true)
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"status", false,
|
||||||
|
"message", "Invalid or expired invitation code",
|
||||||
|
"data", Map.of("code", request.getCode(), "valid", false)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"status", false,
|
||||||
|
"message", "Error validating invitation code"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/register")
|
||||||
|
@Operation(summary = "Register new user", description = "Register a new user with invitation code, phone number, and password")
|
||||||
|
public ResponseEntity<Map<String, Object>> register(@Valid @RequestBody RegisterRequest request) {
|
||||||
|
try {
|
||||||
|
User user = invitationCodeService.registerUserWithCode(
|
||||||
|
request.getCode(),
|
||||||
|
request.getPhoneNumber(),
|
||||||
|
request.getPassword()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auto-login: generate JWT token
|
||||||
|
String token = jwtUtil.generateToken(user.getPhoneNumber());
|
||||||
|
|
||||||
|
RegisterResponse registerResponse = new RegisterResponse(user, token);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"status", true,
|
||||||
|
"message", "User registered successfully",
|
||||||
|
"data", registerResponse
|
||||||
|
));
|
||||||
|
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
String message;
|
||||||
|
if (e.getMessage().contains("already exists")) {
|
||||||
|
message = "A user with this phone number already exists";
|
||||||
|
} else if (e.getMessage().contains("Invalid or expired")) {
|
||||||
|
message = "Invalid or expired invitation code";
|
||||||
|
} else {
|
||||||
|
message = "Registration failed. Please try again";
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"status", false,
|
||||||
|
"message", message
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/admin/createUser")
|
@PostMapping("/admin/createUser")
|
||||||
@Operation(summary = "Create new user (Admin only)", description = "Creates a new user - requires admin privileges")
|
@Operation(summary = "Create new user (Admin only)", description = "Creates a new user - requires admin privileges")
|
||||||
public ResponseEntity<?> createUser(@Valid @RequestBody CreateUserRequest request) {
|
public ResponseEntity<?> createUser(@Valid @RequestBody CreateUserRequest request) {
|
||||||
|
|||||||
@ -0,0 +1,23 @@
|
|||||||
|
package online.wesal.wesal.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public class CheckInvitationRequest {
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
private String code;
|
||||||
|
|
||||||
|
public CheckInvitationRequest() {}
|
||||||
|
|
||||||
|
public CheckInvitationRequest(String code) {
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCode() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCode(String code) {
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
package online.wesal.wesal.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
public class RegisterRequest {
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
private String code;
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
@Pattern(regexp = "^\\+[1-9]\\d{1,14}$", message = "Phone number must be in international format starting with + and country code")
|
||||||
|
private String phoneNumber;
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
@Size(min = 6)
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
public RegisterRequest() {}
|
||||||
|
|
||||||
|
public RegisterRequest(String code, String phoneNumber, String password) {
|
||||||
|
this.code = code;
|
||||||
|
this.phoneNumber = phoneNumber;
|
||||||
|
this.password = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCode() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCode(String code) {
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPhoneNumber() {
|
||||||
|
return phoneNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPhoneNumber(String phoneNumber) {
|
||||||
|
this.phoneNumber = phoneNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPassword() {
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPassword(String password) {
|
||||||
|
this.password = password;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
package online.wesal.wesal.dto;
|
||||||
|
|
||||||
|
import online.wesal.wesal.entity.User;
|
||||||
|
|
||||||
|
public class RegisterResponse {
|
||||||
|
|
||||||
|
private User user;
|
||||||
|
private String token;
|
||||||
|
|
||||||
|
public RegisterResponse() {}
|
||||||
|
|
||||||
|
public RegisterResponse(User user, String token) {
|
||||||
|
this.user = user;
|
||||||
|
this.token = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public User getUser() {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUser(User user) {
|
||||||
|
this.user = user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getToken() {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setToken(String token) {
|
||||||
|
this.token = token;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,72 @@
|
|||||||
|
package online.wesal.wesal.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "invitation_codes")
|
||||||
|
public class InvitationCode {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(unique = true, nullable = false)
|
||||||
|
@NotBlank
|
||||||
|
@Size(max = 200)
|
||||||
|
private String code;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private LocalDateTime expirationDate;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private LocalDateTime createdDate;
|
||||||
|
|
||||||
|
public InvitationCode() {
|
||||||
|
this.createdDate = LocalDateTime.now();
|
||||||
|
this.expirationDate = LocalDateTime.now().plusDays(30);
|
||||||
|
}
|
||||||
|
|
||||||
|
public InvitationCode(String code) {
|
||||||
|
this();
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCode() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCode(String code) {
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getExpirationDate() {
|
||||||
|
return expirationDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setExpirationDate(LocalDateTime expirationDate) {
|
||||||
|
this.expirationDate = expirationDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedDate() {
|
||||||
|
return createdDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedDate(LocalDateTime createdDate) {
|
||||||
|
this.createdDate = createdDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isExpired() {
|
||||||
|
return LocalDateTime.now().isAfter(this.expirationDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -46,7 +46,7 @@ public class User {
|
|||||||
private String role = "USER";
|
private String role = "USER";
|
||||||
|
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String subscriptions = "[\"newinvites\",\"invitesfollowup\",\"appnews\"]";
|
private String subscriptions = "[\"postcomments\",\"newinvites\",\"invitesfollowup\",\"appnews\"]";
|
||||||
|
|
||||||
public User() {}
|
public User() {}
|
||||||
|
|
||||||
@ -130,13 +130,13 @@ public class User {
|
|||||||
|
|
||||||
public List<String> getSubscriptions() {
|
public List<String> getSubscriptions() {
|
||||||
if (subscriptions == null || subscriptions.isEmpty()) {
|
if (subscriptions == null || subscriptions.isEmpty()) {
|
||||||
return Arrays.asList("newinvites", "invitesfollowup", "appnews");
|
return Arrays.asList("postcomments", "newinvites", "invitesfollowup", "appnews");
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
ObjectMapper mapper = new ObjectMapper();
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
return mapper.readValue(subscriptions, new TypeReference<List<String>>() {});
|
return mapper.readValue(subscriptions, new TypeReference<List<String>>() {});
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return Arrays.asList("newinvites", "invitesfollowup", "appnews");
|
return Arrays.asList("postcomments", "newinvites", "invitesfollowup", "appnews");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -145,7 +145,7 @@ public class User {
|
|||||||
ObjectMapper mapper = new ObjectMapper();
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
this.subscriptions = mapper.writeValueAsString(subscriptions);
|
this.subscriptions = mapper.writeValueAsString(subscriptions);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
this.subscriptions = "[\"newinvites\",\"invitesfollowup\",\"appnews\"]";
|
this.subscriptions = "[\"postcomments\",\"newinvites\",\"invitesfollowup\",\"appnews\"]";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
package online.wesal.wesal.repository;
|
||||||
|
|
||||||
|
import online.wesal.wesal.entity.InvitationCode;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface InvitationCodeRepository extends JpaRepository<InvitationCode, Long> {
|
||||||
|
Optional<InvitationCode> findByCode(String code);
|
||||||
|
boolean existsByCode(String code);
|
||||||
|
|
||||||
|
@Query("SELECT ic FROM InvitationCode ic WHERE ic.code = :code AND ic.expirationDate > :currentTime")
|
||||||
|
Optional<InvitationCode> findValidCodeByCode(@Param("code") String code, @Param("currentTime") LocalDateTime currentTime);
|
||||||
|
}
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
package online.wesal.wesal.service;
|
||||||
|
|
||||||
|
import online.wesal.wesal.entity.InvitationCode;
|
||||||
|
import online.wesal.wesal.entity.User;
|
||||||
|
import online.wesal.wesal.repository.InvitationCodeRepository;
|
||||||
|
import online.wesal.wesal.repository.UserRepository;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class InvitationCodeService {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private InvitationCodeRepository invitationCodeRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserRepository userRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
public boolean isValidCode(String code) {
|
||||||
|
Optional<InvitationCode> invitationCodeOpt = invitationCodeRepository.findValidCodeByCode(code, LocalDateTime.now());
|
||||||
|
return invitationCodeOpt.isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<InvitationCode> findValidCode(String code) {
|
||||||
|
return invitationCodeRepository.findValidCodeByCode(code, LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public User registerUserWithCode(String code, String phoneNumber, String password) {
|
||||||
|
// Check if phone number already exists
|
||||||
|
if (userRepository.existsByPhoneNumber(phoneNumber)) {
|
||||||
|
throw new RuntimeException("User with this phone number already exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate invitation code
|
||||||
|
Optional<InvitationCode> invitationCodeOpt = findValidCode(code);
|
||||||
|
if (invitationCodeOpt.isEmpty()) {
|
||||||
|
throw new RuntimeException("Invalid or expired invitation code");
|
||||||
|
}
|
||||||
|
|
||||||
|
InvitationCode invitationCode = invitationCodeOpt.get();
|
||||||
|
|
||||||
|
// Create new user
|
||||||
|
User user = new User();
|
||||||
|
user.setPhoneNumber(phoneNumber);
|
||||||
|
user.setPassword(passwordEncoder.encode(password));
|
||||||
|
user.setDisplayName("Guest");
|
||||||
|
user.setRole("USER");
|
||||||
|
user.setActivated(false);
|
||||||
|
user.setSubscriptions(Arrays.asList("postcomments", "newinvites", "invitesfollowup", "appnews"));
|
||||||
|
|
||||||
|
// Save user
|
||||||
|
User savedUser = userRepository.save(user);
|
||||||
|
|
||||||
|
// Remove (delete) the invitation code so it can't be used again
|
||||||
|
invitationCodeRepository.delete(invitationCode);
|
||||||
|
|
||||||
|
return savedUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user