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())
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.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")
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
|
||||
@ -8,9 +8,14 @@ import online.wesal.wesal.dto.LoginRequest;
|
||||
import online.wesal.wesal.dto.LoginResponse;
|
||||
import online.wesal.wesal.dto.UpdateUserRequest;
|
||||
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.service.AuthService;
|
||||
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.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@ -29,6 +34,12 @@ public class AuthController {
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@Autowired
|
||||
private InvitationCodeService invitationCodeService;
|
||||
|
||||
@Autowired
|
||||
private JwtUtil jwtUtil;
|
||||
|
||||
@GetMapping("/")
|
||||
@Operation(summary = "Health check", description = "Returns API status")
|
||||
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")
|
||||
@Operation(summary = "Create new user (Admin only)", description = "Creates a new user - requires admin privileges")
|
||||
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";
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String subscriptions = "[\"newinvites\",\"invitesfollowup\",\"appnews\"]";
|
||||
private String subscriptions = "[\"postcomments\",\"newinvites\",\"invitesfollowup\",\"appnews\"]";
|
||||
|
||||
public User() {}
|
||||
|
||||
@ -130,13 +130,13 @@ public class User {
|
||||
|
||||
public List<String> getSubscriptions() {
|
||||
if (subscriptions == null || subscriptions.isEmpty()) {
|
||||
return Arrays.asList("newinvites", "invitesfollowup", "appnews");
|
||||
return Arrays.asList("postcomments", "newinvites", "invitesfollowup", "appnews");
|
||||
}
|
||||
try {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
return mapper.readValue(subscriptions, new TypeReference<List<String>>() {});
|
||||
} 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();
|
||||
this.subscriptions = mapper.writeValueAsString(subscriptions);
|
||||
} 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