Bài 29: Authentication – Đăng nhập/Đăng ký

Bài 28 kết nối MVC thành hệ thống hoàn chỉnh. Bài này xây dựng Authentication — hệ thống đăng nhập/đăng ký, quản lý session, bảo vệ password. Bạn sẽ học hash password với bcrypt, middleware kiểm tra đăng nhập, remember me, và cách bảo mật tài khoản người dùng.

1. Database Users

-- File: database/migrations/users.sql

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(100) NOT NULL UNIQUE,
    password VARCHAR(255) NOT NULL,
    role ENUM('user', 'admin') DEFAULT 'user',
    is_active TINYINT DEFAULT 1,
    last_login DATETIME,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    deleted_at DATETIME,
    
    INDEX idx_email (email),
    INDEX idx_role (role)
);

2. User Model

<?php
// File: app/Models/UserModel.php
namespace App\Models;

class UserModel extends BaseModel {
    protected string $table = 'users';
    protected array $fillable = ['name', 'email', 'password', 'role'];
    protected array $hidden = ['password'];
    
    /**
     * Tìm theo email
     */
    public function findByEmail(string $email): ?array {
        $stmt = $this->db->prepare("SELECT * FROM {$this->table} WHERE email = ? AND is_active = 1");
        $stmt->execute([strtolower($email)]);
        $result = $stmt->fetch();
        return $result ?: null;
    }
    
    /**
     * Hash password
     */
    public static function hashPassword(string $password): string {
        return password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
    }
    
    /**
     * Verify password
     */
    public static function verifyPassword(string $password, string $hash): bool {
        return password_verify($password, $hash);
    }
    
    /**
     * Validate dữ liệu đăng ký
     */
    public function validateRegister(array $data): array {
        $errors = [];
        
        if (empty($data['name'])) {
            $errors['name'] = 'Tên không được để trống';
        } elseif (mb_strlen($data['name'], 'UTF-8') < 2) {
            $errors['name'] = 'Tên phải có ít nhất 2 ký tự';
        }
        
        if (empty($data['email'])) {
            $errors['email'] = 'Email không được để trống';
        } elseif (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
            $errors['email'] = 'Email không hợp lệ';
        } elseif ($this->findByEmail($data['email'])) {
            $errors['email'] = 'Email đã tồn tại';
        }
        
        if (empty($data['password'])) {
            $errors['password'] = 'Mật khẩu không được để trống';
        } elseif (strlen($data['password']) < 6) {
            $errors['password'] = 'Mật khẩu phải có ít nhất 6 ký tự';
        }
        
        if (($data['password_confirm'] ?? '') !== $data['password']) {
            $errors['password_confirm'] = 'Mật khẩu không khớp';
        }
        
        return $errors;
    }
    
    /**
     * Validate đăng nhập
     */
    public function validateLogin(array $data): array {
        $errors = [];
        
        if (empty($data['email'])) {
            $errors['email'] = 'Email không được để trống';
        }
        
        if (empty($data['password'])) {
            $errors['password'] = 'Mật khẩu không được để trống';
        }
        
        return $errors;
    }
}
?>

3. Auth Middleware

<?php
// File: app/Middleware/Auth.php
namespace App\Middleware;

class Auth {
    public function handle(array $params): void {
        if (!isset($_SESSION['user_id'])) {
            flash('error', 'Vui lòng đăng nhập để tiếp tục');
            redirect('/login?redirect=' . urlencode($_SERVER['REQUEST_URI']));
        }
    }
}

// ---

// File: app/Middleware/Guest.php
namespace App\Middleware;

class Guest {
    public function handle(array $params): void {
        if (isset($_SESSION['user_id'])) {
            redirect('/');
        }
    }
}

// ---

// File: app/Middleware/Admin.php
namespace App\Middleware;

class Admin {
    public function handle(array $params): void {
        if (!isset($_SESSION['user_id'])) {
            redirect('/login');
        }
        
        if (($_SESSION['user_role'] ?? null) !== 'admin') {
            http_response_code(403);
            echo view('errors.403', ['message' => 'Bạn không có quyền truy cập'], null);
            exit;
        }
    }
}
?>

4. Helper Functions

<?php
// File: app/Helpers/functions.php (bổ sung)

/**
 * Lấy user hiện tại
 */
function auth(): ?array {
    static $user = null;
    
    if ($user === null && isset($_SESSION['user_id'])) {
        $userModel = new \App\Models\UserModel();
        $user = $userModel->find($_SESSION['user_id']) ?: false;
    }
    
    return $user ?: null;
}

/**
 * Kiểm tra đã đăng nhập
 */
function isAuthenticated(): bool {
    return isset($_SESSION['user_id']);
}

/**
 * Kiểm tra là admin
 */
function isAdmin(): bool {
    return isAuthenticated() && ($_SESSION['user_role'] ?? null) === 'admin';
}

/**
 * Đăng nhập user
 */
function loginUser(array $user, bool $remember = false): void {
    $_SESSION['user_id'] = $user['id'];
    $_SESSION['user_name'] = $user['name'];
    $_SESSION['user_email'] = $user['email'];
    $_SESSION['user_role'] = $user['role'];
    
    if ($remember) {
        $token = bin2hex(random_bytes(32));
        $expires = time() + (30 * 24 * 60 * 60); // 30 ngày
        
        setcookie('remember_token', $token, $expires, '/', '', false, true);
        
        // Lưu token vào DB
        $userModel = new \App\Models\UserModel();
        $userModel->update($user['id'], ['remember_token' => $token]);
    }
    
    // Update last_login
    $userModel = $userModel ?? new \App\Models\UserModel();
    $userModel->update($user['id'], ['last_login' => date('Y-m-d H:i:s')]);
}

/**
 * Đăng xuất
 */
function logout(): void {
    session_destroy();
    
    if (isset($_COOKIE['remember_token'])) {
        setcookie('remember_token', '', time() - 3600, '/');
    }
}
?>

5. Auth Controller

<?php
// File: app/Controllers/AuthController.php
namespace App\Controllers;

use App\Models\UserModel;

class AuthController extends BaseController {
    protected UserModel $model;
    
    public function __construct() {
        parent::__construct();
        $this->model = new UserModel();
    }
    
    /**
     * Form đăng nhập
     */
    public function loginForm(array $params): string {
        return view('auth.login', [
            'title' => 'Đăng Nhập',
        ]);
    }
    
    /**
     * Xử lý đăng nhập
     */
    public function login(array $params): void {
        // Validate
        $errors = $this->model->validateLogin($_POST);
        
        if (!empty($errors)) {
            $this->withErrors($errors);
            setOldInput($_POST);
            $this->back();
        }
        
        // Tìm user
        $user = $this->model->findByEmail($_POST['email']);
        
        if (!$user || !UserModel::verifyPassword($_POST['password'], $user['password'])) {
            $this->withErrors(['email' => 'Email hoặc mật khẩu không đúng']);
            setOldInput(['email' => $_POST['email']]);
            $this->back();
        }
        
        // Đăng nhập
        $remember = isset($_POST['remember']) && $_POST['remember'] === 'on';
        loginUser($user, $remember);
        
        clearOldInput();
        flash('success', 'Đăng nhập thành công!');
        
        // Redirect đến redirect param hoặc home
        $redirect = $_GET['redirect'] ?? '/';
        $this->redirect($redirect);
    }
    
    /**
     * Form đăng ký
     */
    public function registerForm(array $params): string {
        return view('auth.register', [
            'title' => 'Đăng Ký',
        ]);
    }
    
    /**
     * Xử lý đăng ký
     */
    public function register(array $params): void {
        // Validate
        $errors = $this->model->validateRegister($_POST);
        
        if (!empty($errors)) {
            $this->withErrors($errors);
            setOldInput(['name' => $_POST['name'] ?? '', 'email' => $_POST['email'] ?? '']);
            $this->back();
        }
        
        try {
            // Tạo user
            $data = [
                'name' => trim($_POST['name']),
                'email' => strtolower(trim($_POST['email'])),
                'password' => UserModel::hashPassword($_POST['password']),
                'role' => 'user',
            ];
            
            $userId = $this->model->create($data);
            
            // Tự động đăng nhập
            $user = $this->model->find($userId);
            loginUser($user);
            
            clearOldInput();
            flash('success', 'Đăng ký thành công! Chào mừng bạn!');
            $this->redirect('/');
            
        } catch (\Exception $e) {
            logMessage('ERROR', $e->getMessage());
            flash('error', 'Có lỗi xảy ra. Vui lòng thử lại.');
            $this->back();
        }
    }
    
    /**
     * Đăng xuất
     */
    public function logout(array $params): void {
        logout();
        
        flash('success', 'Đã đăng xuất!');
        $this->redirect('/');
    }
    
    /**
     * Profile
     */
    public function profile(array $params): string {
        $user = auth();
        
        return view('auth.profile', [
            'title' => 'Hồ Sơ Cá Nhân',
            'user' => $user,
        ]);
    }
    
    /**
     * Cập nhật profile
     */
    public function updateProfile(array $params): void {
        $user = auth();
        
        if (!$user) {
            $this->redirect('/login');
        }
        
        $errors = [];
        
        if (empty($_POST['name'])) {
            $errors['name'] = 'Tên không được để trống';
        }
        
        if (!empty($_POST['email']) && $_POST['email'] !== $user['email']) {
            if (!filter_var($_POST['email'], FILTER_VALIDATE_EMAIL)) {
                $errors['email'] = 'Email không hợp lệ';
            } elseif ($this->model->findByEmail($_POST['email'])) {
                $errors['email'] = 'Email đã tồn tại';
            }
        }
        
        if (!empty($errors)) {
            $this->withErrors($errors);
            $this->back();
        }
        
        try {
            $data = [
                'name' => $_POST['name'],
                'email' => strtolower($_POST['email'] ?? $user['email']),
            ];
            
            $this->model->update($user['id'], $data);
            
            // Cập nhật session
            $_SESSION['user_name'] = $data['name'];
            $_SESSION['user_email'] = $data['email'];
            
            flash('success', 'Cập nhật hồ sơ thành công!');
            $this->redirect('/profile');
            
        } catch (\Exception $e) {
            logMessage('ERROR', $e->getMessage());
            flash('error', 'Có lỗi xảy ra.');
            $this->back();
        }
    }
    
    /**
     * Đổi mật khẩu
     */
    public function changePassword(array $params): void {
        $user = auth();
        
        if (!$user) {
            $this->redirect('/login');
        }
        
        $errors = [];
        
        if (empty($_POST['current_password'])) {
            $errors['current_password'] = 'Mật khẩu hiện tại không được để trống';
        } elseif (!UserModel::verifyPassword($_POST['current_password'], $user['password'])) {
            $errors['current_password'] = 'Mật khẩu không đúng';
        }
        
        if (empty($_POST['password'])) {
            $errors['password'] = 'Mật khẩu mới không được để trống';
        } elseif (strlen($_POST['password']) < 6) {
            $errors['password'] = 'Mật khẩu phải có ít nhất 6 ký tự';
        }
        
        if (($_POST['password_confirm'] ?? '') !== $_POST['password']) {
            $errors['password_confirm'] = 'Mật khẩu không khớp';
        }
        
        if (!empty($errors)) {
            $this->withErrors($errors);
            $this->back();
        }
        
        try {
            $this->model->update($user['id'], [
                'password' => UserModel::hashPassword($_POST['password']),
            ]);
            
            flash('success', 'Đổi mật khẩu thành công!');
            $this->redirect('/profile');
            
        } catch (\Exception $e) {
            logMessage('ERROR', $e->getMessage());
            flash('error', 'Có lỗi xảy ra.');
            $this->back();
        }
    }
}
?>

6. Routes

<?php
// File: config/routes.php (bổ sung)

// Auth routes
$router->group(['middleware' => 'Guest'], function($r) {
    $r->get('/login', 'AuthController@loginForm')->name('login');
    $r->post('/login', 'AuthController@login');
    $r->get('/register', 'AuthController@registerForm')->name('register');
    $r->post('/register', 'AuthController@register');
});

// Protected routes
$router->group(['middleware' => 'Auth'], function($r) {
    $r->get('/logout', 'AuthController@logout')->name('logout');
    $r->get('/profile', 'AuthController@profile')->name('profile');
    $r->post('/profile', 'AuthController@updateProfile');
    $r->post('/change-password', 'AuthController@changePassword');
});
?>

7. Views

<?php
// File: app/Views/auth/login.php
?>
<style>
    .auth-container { max-width: 400px; margin: 50px auto; }
    .auth-form { background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
    .auth-form h2 { margin-bottom: 1.5rem; text-align: center; }
    .form-group { margin-bottom: 1rem; }
    .form-group label { display: block; margin-bottom: 0.5rem; font-weight: 500; }
    .form-group input { width: 100%; padding: 0.75rem; border: 1px solid #ddd; border-radius: 4px; }
    .form-group input:focus { outline: none; border-color: #3498db; }
    .form-group small { color: red; display: block; margin-top: 0.25rem; }
    .checkbox { display: flex; align-items: center; margin-bottom: 1rem; }
    .checkbox input { margin-right: 0.5rem; }
    .btn { width: 100%; padding: 0.75rem; background: #3498db; color: white; border: none; border-radius: 4px; cursor: pointer; }
    .btn:hover { background: #2980b9; }
    .auth-footer { text-align: center; margin-top: 1rem; }
    .auth-footer a { color: #3498db; text-decoration: none; }
</style>

<div class="auth-container">
    <div class="auth-form">
        <h2>Đăng Nhập</h2>
        
        <?php echo partial('partials.alerts', ['errors' => $errors ?? []]); ?>
        
        <form method="POST">
            <div class="form-group">
                <label>Email</label>
                <input type="email" name="email" value="<?php echo e(old('email', '')); ?>" required>
                <?php if (isset($errors['email'])): ?>
                    <small><?php echo e($errors['email']); ?></small>
                <?php endif; ?>
            </div>
            
            <div class="form-group">
                <label>Mật khẩu</label>
                <input type="password" name="password" required>
                <?php if (isset($errors['password'])): ?>
                    <small><?php echo e($errors['password']); ?></small>
                <?php endif; ?>
            </div>
            
            <div class="checkbox">
                <input type="checkbox" name="remember" id="remember">
                <label for="remember" style="margin: 0;">Ghi nhớ tôi</label>
            </div>
            
            <button type="submit" class="btn">Đăng Nhập</button>
        </form>
        
        <div class="auth-footer">
            Chưa có tài khoản? <a href="/register">Đăng ký ngay</a>
        </div>
    </div>
</div>
<?php
// File: app/Views/auth/register.php
?>
<style>
    .auth-container { max-width: 400px; margin: 50px auto; }
    .auth-form { background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
    .auth-form h2 { margin-bottom: 1.5rem; text-align: center; }
    .form-group { margin-bottom: 1rem; }
    .form-group label { display: block; margin-bottom: 0.5rem; font-weight: 500; }
    .form-group input { width: 100%; padding: 0.75rem; border: 1px solid #ddd; border-radius: 4px; }
    .form-group input:focus { outline: none; border-color: #3498db; }
    .form-group small { color: red; display: block; margin-top: 0.25rem; }
    .btn { width: 100%; padding: 0.75rem; background: #2ecc71; color: white; border: none; border-radius: 4px; cursor: pointer; }
    .btn:hover { background: #27ae60; }
    .auth-footer { text-align: center; margin-top: 1rem; }
    .auth-footer a { color: #3498db; text-decoration: none; }
</style>

<div class="auth-container">
    <div class="auth-form">
        <h2>Đăng Ký</h2>
        
        <?php echo partial('partials.alerts', ['errors' => $errors ?? []]); ?>
        
        <form method="POST">
            <div class="form-group">
                <label>Tên</label>
                <input type="text" name="name" value="<?php echo e(old('name', '')); ?>" required>
                <?php if (isset($errors['name'])): ?>
                    <small><?php echo e($errors['name']); ?></small>
                <?php endif; ?>
            </div>
            
            <div class="form-group">
                <label>Email</label>
                <input type="email" name="email" value="<?php echo e(old('email', '')); ?>" required>
                <?php if (isset($errors['email'])): ?>
                    <small><?php echo e($errors['email']); ?></small>
                <?php endif; ?>
            </div>
            
            <div class="form-group">
                <label>Mật khẩu</label>
                <input type="password" name="password" required>
                <?php if (isset($errors['password'])): ?>
                    <small><?php echo e($errors['password']); ?></small>
                <?php endif; ?>
            </div>
            
            <div class="form-group">
                <label>Xác nhận mật khẩu</label>
                <input type="password" name="password_confirm" required>
                <?php if (isset($errors['password_confirm'])): ?>
                    <small><?php echo e($errors['password_confirm']); ?></small>
                <?php endif; ?>
            </div>
            
            <button type="submit" class="btn">Đăng Ký</button>
        </form>
        
        <div class="auth-footer">
            Đã có tài khoản? <a href="/login">Đăng nhập</a>
        </div>
    </div>
</div>
<?php
// File: app/Views/auth/profile.php
?>
<div class="card">
    <h2>Hồ Sơ Cá Nhân</h2>
    
    <?php echo partial('partials.alerts', ['errors' => $errors ?? []]); ?>
    
    <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; margin-top: 2rem;">
        <!-- Cập nhật thông tin -->
        <div>
            <h3>Thông Tin Cơ Bản</h3>
            
            <form method="POST" action="/profile" style="margin-top: 1rem;">
                <div style="margin-bottom: 1rem;">
                    <label style="display: block; margin-bottom: 0.5rem; font-weight: 500;">Tên</label>
                    <input type="text" name="name" value="<?php echo e($user['name']); ?>"
                           style="width: 100%; padding: 0.75rem; border: 1px solid #ddd; border-radius: 4px;" required>
                    <?php if (isset($errors['name'])): ?>
                        <small style="color: red;"><?php echo e($errors['name']); ?></small>
                    <?php endif; ?>
                </div>
                
                <div style="margin-bottom: 1rem;">
                    <label style="display: block; margin-bottom: 0.5rem; font-weight: 500;">Email</label>
                    <input type="email" name="email" value="<?php echo e($user['email']); ?>"
                           style="width: 100%; padding: 0.75rem; border: 1px solid #ddd; border-radius: 4px;" required>
                    <?php if (isset($errors['email'])): ?>
                        <small style="color: red;"><?php echo e($errors['email']); ?></small>
                    <?php endif; ?>
                </div>
                
                <button type="submit" class="btn btn-primary">Lưu Thay Đổi</button>
            </form>
        </div>
        
        <!-- Đổi mật khẩu -->
        <div>
            <h3>Đổi Mật Khẩu</h3>
            
            <form method="POST" action="/change-password" style="margin-top: 1rem;">
                <div style="margin-bottom: 1rem;">
                    <label style="display: block; margin-bottom: 0.5rem; font-weight: 500;">Mật Khẩu Hiện Tại</label>
                    <input type="password" name="current_password"
                           style="width: 100%; padding: 0.75rem; border: 1px solid #ddd; border-radius: 4px;" required>
                    <?php if (isset($errors['current_password'])): ?>
                        <small style="color: red;"><?php echo e($errors['current_password']); ?></small>
                    <?php endif; ?>
                </div>
                
                <div style="margin-bottom: 1rem;">
                    <label style="display: block; margin-bottom: 0.5rem; font-weight: 500;">Mật Khẩu Mới</label>
                    <input type="password" name="password"
                           style="width: 100%; padding: 0.75rem; border: 1px solid #ddd; border-radius: 4px;" required>
                    <?php if (isset($errors['password'])): ?>
                        <small style="color: red;"><?php echo e($errors['password']); ?></small>
                    <?php endif; ?>
                </div>
                
                <div style="margin-bottom: 1rem;">
                    <label style="display: block; margin-bottom: 0.5rem; font-weight: 500;">Xác Nhận</label>
                    <input type="password" name="password_confirm"
                           style="width: 100%; padding: 0.75rem; border: 1px solid #ddd; border-radius: 4px;" required>
                    <?php if (isset($errors['password_confirm'])): ?>
                        <small style="color: red;"><?php echo e($errors['password_confirm']); ?></small>
                    <?php endif; ?>
                </div>
                
                <button type="submit" class="btn btn-primary">Đổi Mật Khẩu</button>
            </form>
        </div>
    </div>
</div>

8. Navigation với Auth

<?php
// File: app/Views/layouts/main.php (bổ sung)

<nav>
    <a href="/">Trang chủ</a>
    <a href="/sinhvien">Sinh viên</a>
    <a href="/monhoc">Môn học</a>
    
    <?php if (isAuthenticated()): ?>
        <a href="/profile">Hồ sơ</a>
        <?php if (isAdmin()): ?>
            <a href="/admin">Quản trị</a>
        <?php endif; ?>
        <a href="/logout">Đăng xuất</a>
    <?php else: ?>
        <a href="/login">Đăng nhập</a>
        <a href="/register">Đăng ký</a>
    <?php endif; ?>
</nav>

Tóm tắt

  • ✅ Hash password với password_hash() + PASSWORD_BCRYPT
  • ✅ Verify password với password_verify()
  • ✅ Session: lưu user_id, user_role
  • ✅ Middleware Auth kiểm tra đăng nhập
  • ✅ Middleware Guest cho login/register
  • ✅ Helper: auth(), isAuthenticated(), isAdmin()
  • ✅ Remember me với cookie + token
  • ✅ Đổi mật khẩu, cập nhật profile
  • ✅ Flash message cho thành công/lỗi

🎯 Bài tập

  1. Quên mật khẩu: Thêm forgot-password form, gửi reset link qua email (fake, in console), đặt lại mật khẩu.
  2. Email verification: Khi đăng ký, gửi verification email, chỉ cho đăng nhập sau khi verify.
  3. OAuth: Thêm đăng nhập Google/Facebook (nếu biết). Hoặc giả lập login bằng cách khác.

Để lại bình luận

Email của bạn sẽ không được hiển thị.