Güncelleme 02.05.2020: csrf().disabled()
konfigürasyonu ve CustomAuthenticationProvider sınıfı kaldırıldı. Ayrıca Optional kullanımını değiştirdim ve orElseThrow()
metodunu kullandım. Değerli yorumları için @gokcensedat ve @utkuozdemir‘e teşekkür ederim.
Merhaba, bu yazımda Spring Boot uygulamasında kullanıcı kaydı, onay maili gönderme ve kullanıcı girişi işlemlerinin nasıl yapılacağını anlatacağım. Ana konudan sapmamak için exception handling kısımlarından bahsetmeyeceğim. Spring Boot hakkında bilgi sahibi olmanız, tercihen bir IDE kullanmanız gerekmektedir.
Doğrudan kaynak kodları incelemek için: https://github.com/kamer/spring-boot-user-registration
└── main
├── java
│ └── com
│ └── kamer
│ └── springbootuserregistration
│ ├── config
│ │ ├── CustomAuthenticationProvider.java
│ │ ├── WebConfig.java
│ │ └── WebSecurityConfig.java
│ ├── entity
│ │ ├── User.java
│ │ └── UserRole.java
│ ├── SpringBootUserRegistrationApplication.java
│ └── user
│ ├── ConfirmationToken.java
│ ├── ConfirmationTokenRepository.java
│ ├── ConfirmationTokenService.java
│ ├── EmailSenderService.java
│ ├── UserController.java
│ ├── UserRepository.java
│ └── UserService.java
└── resources
├── application.yml
├── static
└── templates
├── sign-in.html
└── sign-up.html
Spring Initialzr ile Projenin Oluşturulması
https://start.spring.io/ adresine giderek veya kullandığınız IDE’nin ilgili plugin’lerini kullanarak bir proje oluşturun. Projede Spring Web, Lombok, Thymeleaf, Spring Security, Java Mail Sender, H2 ve Spring Data JPA dependency’lerini seçin. Tercihen farklı bir veritabanı da kullanabilirsiniz.
User Entity’sinin Oluşturulması
entity adında bir package oluşturun ve içinde UserRole adında bir enum yaratın. Kullanıcı rollerini tutacağımız bu enumda ADMIN ve USER adında iki değer olacak.
enum UserRole {
ADMIN, USER
}
Sonra User adında bir class yaratın. Bu class UserDetails interface’ini implement etmeli. UserDetails interface’i temel kullanıcı bilgilerinin metotlarını barındıran bir interface. Örnek bir kullanımını görmek için org.springframework.security.core.userdetails.User sınıfına bakabilirsiniz. Hatta doğrudan bu sınıfı extend edip eklemek istediğiniz değişkenleri ekleyebilirsiniz. Ben extend etmeden sıfırdan bir class oluşturacağım. Birkaç değişken oluşturup UserDetails’dan gelen metotların bodylerini dolduracağız.
@Setter
@Builder
@EqualsAndHashCode
@NoArgsConstructor
@AllArgsConstructor
@Entity(name = "Users")
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String surname;
private String email;
private String password;
@Builder.Default
private UserRole userRole = UserRole.USER;
@Builder.Default
private Boolean locked = false;
@Builder.Default
private Boolean enabled = false;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
final SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(userRole.name());
return Collections.singletonList(simpleGrantedAuthority);
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return email;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return !locked;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return enabled;
}
}
expired değişkenini kullanmayacağım için isAccountNonExpired ve isCredentialsNonExpired metotlarından true döndürdüm. Username yerine email kullanacağım için de getUsername metodundan email döndürdüm. Diğerlerini metot isimlerinden çıkarabilirsiniz.
UserService’in Oluşturulması
Kullanıcı kaydı işlemlerini yapacağımız UserService classı için user adında bir package oluşturacağız. Bu class da UserDetailsService interfaceini implement edecek. Bu interfaceten loadByUsername adında bir metot alacağız. email kullanacağımız için bu sınıf da adı loadByUsername olsa da mail parametresi alıp ona göre kullanıcı döndürecek.
Tabii veritabanından kullanıcı çekmek için önce repository’e ihtiyacımız var. Hemen aynı sınıf içinde UserRepository interfacei oluşturuyoruz ve içinde findByEmail metot imzasını oluşturuyoruz.
interface UserRepository extends CrudRepository<User, Long> {
Optional<User> findByEmail(String email);
}
Şimdi UserService’e geri dönüp loadByUsername metodunun içini doldurabiliriz.
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
final Optional<User> optionalUser = userRepository.findByEmail(email);
if (optionalUser.isPresent()) {
return optionalUser.get();
}
else {
throw new UsernameNotFoundException(MessageFormat.format("User with email {0} cannot be found.", email));
}
}
ConfirmationToken Classının Oluşturulması
Her kullanıcı kaydolduğunda bir token oluşturacağız ve göndereceğimiz mailde bu token ile eşsiz bir link oluşturacağız. user packageı içinde ConfirmationToken adında bir class yaratıyoruz ve aşağıdaki şekilde dolduruyoruz.
@Entity
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
class ConfirmationToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String confirmationToken;
private LocalDate createdDate;
@OneToOne(targetEntity = User.class, fetch = FetchType.EAGER)
@JoinColumn(nullable = false, name = "user_id")
private User user;
ConfirmationToken(User user) {
this.user = user;
this.createdDate = LocalDate.now();
this.confirmationToken = UUID.randomUUID().toString();
}
}
Her kullanıcıya bir token vereceğimiz için OneToOne ilişki kuruyoruz ve tokenların bir geçerlilik süresi olacağı için createdDate değişkeni tutuyoruz.
Örnek Kullanıcı Kaydı Senaryosuna Göre Metotların Yazılması
Servisleri ve tüm metotları en baştan yazmak yerine aşama aşama gitmeyi daha doğru buluyorum. Böylece gerçek bir geliştirme akışına daha yakın bir yazı olacak.
Kullanıcı kaydında önce kullanıcı bilgilerini alacağız. Sonra BCryptPasswordEncoder ile parolayı encode edeceğiz ve kullanıcıyı enabled=false(default) olacak şekilde kaydedeceğiz. Sonra bir ConfirmationToken oluşuturup bu token’ı kullanıcı ile ilişkilendireceğiz. Token ile eşsiz bir link oluşturup kullanıcıya mail yoluyla göndereceğiz. Kullanıcı bu linke tıkladığında ise ilgili kullanıcının enabled alanı true olacak ve token silinecek.
signUpUser metodunu oluşturmadan önce ConfirmationTokenRepository ve ConfirmationTokenService oluşturup yeni token kaydetmek için bir metot oluşturacağız.
@Repository
interface ConfirmationTokenRepository extends CrudRepository<ConfirmationToken, Long> {
}
@Service
@AllArgsConstructor
class ConfirmationTokenService {
private final ConfirmationTokenRepository confirmationTokenRepository;
void saveConfirmationToken(ConfirmationToken confirmationToken) {
confirmationTokenRepository.save(confirmationToken);
}
Şimdi UserServicee gidip signUpUser metodunu yazalım.
void signUpUser(User user) {
final String encryptedPassword = bCryptPasswordEncoder.encode(user.getPassword());
user.setPassword(encryptedPassword);
final User createdUser = userRepository.save(user);
final ConfirmationToken confirmationToken = new ConfirmationToken(user);
confirmationTokenService.saveConfirmationToken(confirmationToken);
}
Mail Confirmation Metotlarının Yazılması
Senaryoda kullanıcı, kendisine gelen maile tıkladığında çalışacak olan metodu yazalım. Önce link ile maili onayladıktan sonra token‘ı silecek olan metodu yazalım.
void deleteConfirmationToken(Long id){
confirmationTokenRepository.deleteById(id);
}
Şimdi de maili confirm edecek olan metodu yazalım.
void confirmUser(ConfirmationToken confirmationToken) {
final User user = confirmationToken.getUser();
user.setEnabled(true);
userRepository.save(user);
confirmationTokenService.deleteConfirmationToken(confirmationToken.getId());
}
Spring Mail Ayarları
Ben Gmail için örnek bir yml dosyasını aşağıda paylaşıyorum. Eğer 2-factor kullanıyorsanız uygulama için özel bir parola oluşturmanız gerekiyor. (https://support.google.com/mail/answer/185833?hl=en) yml yerine application.properties de kullanabilirsiniz.
spring:
mail:
host: smtp.gmail.com
port: 587
username: <MAIL ADRESI>
password: <PAROLA>
properties:
mail:
smtp:
auth: true
starttls:
enable: true
connectiontimeout: 5000
timeout: 3000
writetimeout: 5000
EmailService’in ve Mail Gönderen Metodun Yazılması
user package‘ı içinde EmailSenderService adında bir class oluşturun ve aşağıdaki şekilde doldurun.
@Service
@AllArgsConstructor
public class EmailSenderService {
private JavaMailSender javaMailSender;
@Async
public void sendEmail(SimpleMailMessage email) {
javaMailSender.send(email);
}
}
Şimdi de UserService içinde mail gönderen metodu yazalım.
void sendConfirmationMail(String userMail, String token) {
final SimpleMailMessage mailMessage = new SimpleMailMessage();
mailMessage.setTo(userMail);
mailMessage.setSubject("Mail Confirmation Link!");
mailMessage.setFrom("<MAIL>");
mailMessage.setText(
"Thank you for registering. Please click on the below link to activate your account." + "http://localhost:8080/sign-up/confirm?token="
+ token);
emailSenderService.sendEmail(mailMessage);
}
WebSecurityConfig ve WebConfig Class’ı ile Security Konfigürasyonunun Yapılması
@Configuration
@AllArgsConstructor
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
private final UserService userService;
private final BCryptPasswordEncoder bCryptPasswordEncoder;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/sign-up/**", "/sign-in/**")
.permitAll()
.anyRequest()
.authenticated()
.and()
.formLogin()
.loginPage("/sign-in")
.permitAll();
}
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userService)
.passwordEncoder(bCryptPasswordEncoder);
}
}
@Configuration
public class WebConfig {
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
Burada hangi sayfaların authentication olmadan kullanılabileceğini, giriş işlemini hangi path‘in handle edeceğini seçiyoruz.
Controller’ların Yazılması ve Sayfaların Oluşturulması
@Controller
@AllArgsConstructor
public class UserController {
private final UserService userService;
private final ConfirmationTokenService confirmationTokenService;
@GetMapping("/sign-in")
String signIn() {
return "sign-in";
}
@GetMapping("/sign-up")
String signUp() {
return "sign-up";
}
@PostMapping("/sign-up")
String signUp(User user) {
userService.signUpUser(user);
return "redirect:/sign-in";
}
@GetMapping("/confirm")
String confirmMail(@RequestParam("token") String token) {
Optional<ConfirmationToken> optionalConfirmationToken = confirmationTokenService.findConfirmationTokenByToken(token);
optionalConfirmationToken.ifPresent(userService::confirmUser);
return "/sign-in";
}
}
Şimdi de controller‘da yönlendirdiğimiz sayfaları hazırlayalım.
Thymeleaf ile Sayfaların Oluşturulması
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="en">
<head>
<title>Spring Boot User Authentication</title>
</head>
<body>
<form role="form" th:action="@{/sign-in}" th:method="post">
<lable>Username</lable>
<input type="text" id="username" name="username">
<lable>Password</lable>
<input type="password" id="password" name="password">
<input type="submit">
</form>
</body>
</html>
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="en">
<head>
<title>Spring Boot User Authentication</title>
</head>
<body>
<form role="form" th:action="@{/sign-up}" th:method="post" th:object="${user}">
<lable>Name</lable>
<input type="text" id="name" name="name" th:field="*{name}">
<lable>Surname</lable>
<input type="text" id="surname" name="surname" th:field="*{surname}">
<lable>Email</lable>
<input type="text" id="email" name="email" th:field="*{email}">
<lable>Password</lable>
<input type="password" id="password" name="password" th:field="*{password}">
<input type="submit">
</form>
</body>
</html>
Buraya kadar eksiksiz takip ettiyseniz http://localhost:8080/sign-up adresinden kullanıcı kaydı yapabilirsiniz. Tabii ki bu haliyle yetersiz bir proje. Çünkü exception handling kısımlarını eklemedim. Aynı zamanda token‘ların geçerliliğini kontrol etme gibi şeyleri de kontrol etmedim. Bunlar basit işlemler olduğu için yalnızca üye kaydı ve girişi yapacağınız kısımdan bahsettim.