Spring Boot Uygulamasında Üye Kaydı ve Girişi

06/07/2019

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 isCredentialsNonExpiredmetotları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 interface’i 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 token’ları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 tokensilinecek.

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 UserService’e 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ı

Senayoda 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);
}

CustomAuthenticationProvider Class’ının Yazılması

Girdiğimiz mail ve parolayı doğrulayacak olan ve bize authentication verecek olan class’ı config package’ı içinde oluşturuyoruz. Bu class’a ait daha fazla şey öğrenmek için Spring Security’nin Spring Security Architecture başlıklı yazısını okuyabilirsiniz. Güzel bir yazıdır, kesinlikle çok faydası olacaktır.

@AllArgsConstructor
public class CustomAuthenticationProvider extends DaoAuthenticationProvider {

    @Override
    protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication)
            throws AuthenticationException {

        super.additionalAuthenticationChecks(userDetails, authentication);
    }
}

WebSecurityConfig ve WebConfig Class’ı ile Security Konfigürasyonunun Yapılması

@Configuration
@AllArgsConstructor
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    private final PasswordEncoder bCryptPasswordEncoder;

    private final UserService userService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http.csrf()
                .disable()
                .authorizeRequests()
                .antMatchers("/sign-up/**", "/sign-in/**")
                .permitAll()
                .anyRequest()
                .authenticated()
                .and()
                .formLogin()
                .loginPage("/sign-in")
                .permitAll();
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {

        final CustomAuthenticationProvider authenticationProvider = new CustomAuthenticationProvider();
        authenticationProvider.setUserDetailsService(userService);
        authenticationProvider.setPasswordEncoder(bCryptPasswordEncoder);
        auth.authenticationProvider(authenticationProvider);
    }

}
@Configuration
public class WebConfig {

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {

        return new BCryptPasswordEncoder();
    }
}

Burada hangi sayfaların authetication olmadan kullanılabileceğini, giriş işlemini hangi path’in handleedeceğ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 bir işlemler olduğu için yalnızca üye kaydı ve girişi yapacağınız kısımdan bahsettim.

Sorularınız, önerileriniz ve düzeltmeleriniz için:

Twitter: https://twitter.com/kamer_ee

Mail: kamer@kamerelciyar.com