Search by

    Terakhir diperbaharui: Jan 1, 2021

    Implementasi sistem autentikasi - Login

    Selanjutnya membuat sistem login.

    Alur autentikasi pada halaman Login

    login workflow

    Step by step

    • Konfigurasi PassportJS strategies untuk login user
    • Menambah route untuk menangani login user
    • Membuat halaman login
    • Integrasi API

    Konfigurasi strategi login

    Tambahkan code berikut pada file auth.js.

    dinotes-api/utils/auth.js

    1...
    2passport.use(
    3 'login',
    4 new localStrategy(
    5 {
    6 usernameField: 'email',
    7 passwordField: 'password'
    8 },
    9 async (email, password, done) => {
    10 try {
    11 const db = getDb();
    12 const user = await db.collection('users').findOne({ username: email });
    13
    14 if (!user) {
    15 return done(null, false, { message: 'User not found' });
    16 }
    17
    18 const validate = await bcrypt.compare(password, user.password);
    19
    20 if (!validate) {
    21 return done(null, false, { message: 'Wrong Password' });
    22 }
    23
    24 return done(null, user, { message: 'Logged in Successfully' });
    25 } catch (error) {
    26 return done(error);
    27 }
    28 }
    29 )
    30);
    31...

    Pada code di atas, yang dilakukan adalah:

    • Cek data user yang login di dalam database
    • Jika user ketemu, cocokan password dengan function bcrypt.compare
    • Jika username dan password yang dimasukan sudah sesuai eksekusi callback return done(null, user, { message: 'Logged in Successfully' });
    • Object user yang mewakili identitas user yang sudah divalidasi akan dikirimkan ke handler untuk router

    Update handler & route

    Tambahkan custom callback di handler:

    handler.js

    1...
    2exports.login = async (req, res, next) => {
    3 passport.authenticate('login', async (err, user, info) => {
    4 try {
    5 if (err || !user) {
    6 const error = new Error('An error occurred.');
    7
    8 return next(error);
    9 }
    10
    11 req.login(user, { session: false }, async (error) => {
    12 if (error) return next(error);
    13
    14 const body = { _id: user._id, username: user.username };
    15 const token = jwt.sign(body, 'mys3cret');
    16
    17 return res.json({ user: body, token });
    18 });
    19 } catch (error) {
    20 return next(error);
    21 }
    22 })(req, res, next);
    23};

    Ada yang perlu diperhatikan disini.

    Proses login akan dilakukan dengan menambahkan JWT atau JSON Web Token, JWT ini berfungsi untuk menghasilkan token yang digunakan untuk proses authentikasi antara client dengan API.

    JSON Web Token adalah standard yang digunakan untuk melakukan proses transimi data dalam bentuk JSON.

    Baris code yang bertugas untuk menghasilkan token adalah:

    1const body = { _id: user._id, username: user.username };
    2const token = jwt.sign(body, 'mys3cret');

    Token yang dihasilkan akan dikirim ke client/user untuk selanjutnya disimpan di dalam localStorage atau session di browser.

    string mys3cret adalah sandi yang digunakan untuk menghasilkan dan membongkar token, kita bisa menggantinya dengan yang lain.

    Untuk bisa menggunakan JWT install package jsonwebtoken.

    1yarn add jsonwebtoken

    Import package:

    1...
    2const jwt = require('jsonwebtoken');
    3...

    Update file route.js dengan menambahkan endpoint untuk menangani proses login.

    routes.js

    1...
    2router.post('/login', login);
    3...

    Buat halaman login

    Langkah-langkah berikut ini dilakukan di dalam project dinotest-client dan bukan dinotes-api

    Buat sebuah file baru Login.js di dalam folder pages, kemudian salin code berikut:

    1import React, { useState } from 'react';
    2import tw from 'twin.macro';
    3import { Formik } from 'formik';
    4
    5import logo from '../assets/images/header-logo.png';
    6import Message from '../components/ui/Message';
    7
    8const Container = tw.div`min-h-screen flex items-center justify-center bg-gray-50 py-8 px-4 sm:px-6 lg:px-8`;
    9const Img = tw.img`w-auto m-auto sm:h-16`;
    10const FormContainer = tw.div`max-w-md w-full space-y-8`;
    11const Title = tw.h2`mt-6 text-center text-3xl font-extrabold text-gray-900`;
    12const LoginForm = tw.form`mt-8 space-y-6`;
    13const LoginFormGroup = tw.div`rounded-md shadow-sm -space-y-px`;
    14const EmailField = tw.input`appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm`;
    15const PasswordField = tw.input`appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm`;
    16const Button = tw.button`relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500`;
    17
    18const InfoWrapper = (props) => {
    19 const { status } = props;
    20
    21 if (status !== null) {
    22 if (status === false) {
    23 return <Message type="error" text="Something wrong" />;
    24 }
    25 return <Message type="success" text="Login successful!" />;
    26 }
    27 return <></>;
    28};
    29
    30const LoginPage = () => {
    31 const [isSuccess, setIsSuccess] = useState(null);
    32
    33 return (
    34 <Container>
    35 <FormContainer>
    36 <Img src={logo} alt="logo" />
    37 <p>
    38 <i>"Once upon a time, dinosaur use app to take a note"</i>
    39 </p>
    40 <Title>Login to DinoTes</Title>
    41 <InfoWrapper status={isSuccess} />
    42 <Formik
    43 initialValues={{ email: '', password: '' }}
    44 validate={(values) => {
    45 const errors = {};
    46 if (!values.email) {
    47 errors.email = 'Required';
    48 } else if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(values.email)) {
    49 errors.email = 'Invalid email address';
    50 }
    51 return errors;
    52 }}
    53 onSubmit={async (values, { setSubmitting }) => {
    54 setSubmitting(false);
    55 }}
    56 >
    57 {({ values, errors, touched, handleChange, handleSubmit, isSubmitting }) => (
    58 <LoginForm onSubmit={handleSubmit}>
    59 <LoginFormGroup>
    60 <EmailField
    61 type="email"
    62 name="email"
    63 placeholder="Email Address"
    64 onChange={handleChange}
    65 value={values.email}
    66 />
    67 {errors.email && touched.email && errors.email}
    68 <PasswordField
    69 type="password"
    70 name="password"
    71 placeholder="Password"
    72 onChange={handleChange}
    73 value={values.password}
    74 />
    75 {errors.password && touched.password && errors.password}
    76 </LoginFormGroup>
    77 <Button type="submit" disabled={isSubmitting}>
    78 Login
    79 </Button>
    80 <p>
    81 Don't have an account?{' '}
    82 <Link to="/register">
    83 <b>Register</b>
    84 </Link>
    85 </p>
    86 </LoginForm>
    87 )}
    88 </Formik>
    89 </FormContainer>
    90 </Container>
    91 );
    92};
    93
    94export default LoginPage;

    Kemudian Update route di file App.js.

    src/App.js

    1import React from 'react';
    2import { Route, Switch } from 'react-router-dom';
    3import tw, { GlobalStyles } from 'twin.macro';
    4import HomePage from './pages/Home';
    5import AddPage from './pages/Add';
    6import EditPage from './pages/Edit';
    7import RegisterPage from './pages/Register';
    8import LoginPage from './pages/Login';
    9
    10const Container = tw.div`text-center`;
    11
    12function App() {
    13 return (
    14 <div>
    15 <GlobalStyles />
    16 <Container>
    17 <Switch>
    18 <Route path="/register">
    19 <RegisterPage />
    20 </Route>
    21 <Route path="/login">
    22 <LoginPage />
    23 </Route>
    24 <Route path="/add">
    25 <AddPage />
    26 </Route>
    27 <Route path="/edit/:id">
    28 <EditPage />
    29 </Route>
    30 <Route path="/">
    31 <HomePage />
    32 </Route>
    33 </Switch>
    34 </Container>
    35 </div>
    36 );
    37}
    38
    39export default App;

    Integrasi API

    Update file userSlice.js:

    1import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
    2
    3const userObj = JSON.parse(localStorage.getItem('user'));
    4const isLoggedIn = userObj ? true : false;
    5
    6const initialState = {
    7 isLoggedIn: isLoggedIn,
    8 user: userObj?.user,
    9 status: 'idle',
    10 error: null
    11};
    12
    13export const register = createAsyncThunk('users/register', async (credential) => {
    14 const requestOptions = {
    15 method: 'POST',
    16 headers: { 'Content-Type': 'application/json' },
    17 body: JSON.stringify(credential)
    18 };
    19
    20 const response = await fetch(`${process.env.REACT_APP_API_URL}/register`, requestOptions);
    21
    22 if (response.ok) {
    23 return response;
    24 } else {
    25 throw Error(response.statusText);
    26 }
    27});
    28
    29export const login = createAsyncThunk('users/login', async (credential) => {
    30 const requestOptions = {
    31 method: 'POST',
    32 headers: { 'Content-Type': 'application/json' },
    33 body: JSON.stringify(credential)
    34 };
    35
    36 const response = await fetch(`${process.env.REACT_APP_API_URL}/login`, requestOptions);
    37
    38 if (response.ok) {
    39 const data = await response.json();
    40 localStorage.setItem('user', JSON.stringify(data));
    41 return data;
    42 } else {
    43 throw Error(response.statusText);
    44 }
    45});
    46
    47const userSlice = createSlice({
    48 name: 'user',
    49 initialState,
    50 reducers: {
    51 statusReset(state, action) {
    52 state.status = 'idle';
    53 }
    54 },
    55 extraReducers: {
    56 [login.pending]: (state, action) => {
    57 state.status = 'loading';
    58 },
    59 [login.fulfilled]: (state, action) => {
    60 state.status = 'succeeded';
    61 state.isLoggedIn = true;
    62 state.user = action.payload;
    63 },
    64 [login.rejected]: (state, action) => {
    65 state.status = 'failed';
    66 state.error = action.error.message;
    67 },
    68 [register.pending]: (state, action) => {
    69 state.status = 'loading';
    70 },
    71 [register.fulfilled]: (state, action) => {
    72 state.status = 'succeeded';
    73 },
    74 [register.rejected]: (state, action) => {
    75 state.status = 'failed';
    76 state.error = action.error.message;
    77 }
    78 }
    79});
    80
    81export const { statusReset } = userSlice.actions;
    82
    83export default userSlice.reducer;

    Seperti yang disebutkan sebelumnya bahwa kita menyimpan token yang berasal dari server ke dalam localStorage.

    Baris code yang bertugas untuk hal tersebut adalah:

    1localStorage.setItem('user', JSON.stringify(data));

    Praktek menyimpan JWT di dalam localStorage adalah praktek yang umum dilakukan dalam suatu proses autentikasi aplikasi.

    Hal ini bertujuan agar user tidak kehilangan akses masuk ke aplikasi setiap kali browser direfresh, ditutup atau ketika koneksi ke server putus. Dan juga untuk memudahkan proses autentikasi pada API endpoint.

    Kemudian kita tambahkan dua properties baru di dalam object initialState yaitu isLoggedIn dan user.

    isLoggedIn, akan kita gunakan untuk memeriksa status login dari user dan juga akan digunakan untuk conditional rendering setiap halaman.

    Value dari isLoggedIn adalah true jika pada aplikasi ditemukan localStorage item bernama 'user'.

    1const userObj = JSON.parse(localStorage.getItem('user'));
    2const isLoggedIn = userObj ? true : false;

    Sedangkan user berisi data _id dan username yang bisa dimanfaatkan untuk kebutuhan aplikasi di sisi client, seperti menampilkan profil dll.

    Langkah selanjutnya adalah update halaman Login.js untuk menghubungkan antara reducer dengan view.

    src/pages/Login.js

    1...
    2import { Link, Redirect } from 'react-router-dom'
    3import { unwrapResult } from '@reduxjs/toolkit';
    4import { useDispatch, useSelector } from 'react-redux';
    5...
    6const LoginPage = () => {
    7 const dispatch = useDispatch();
    8 const isLoggedIn = useSelector((state) => state.user.isLoggedIn);
    9 const [isSuccess, setIsSuccess] = useState(null);
    10
    11 return (
    12 <>
    13 {isLoggedIn ? (
    14 <Redirect to="/" />
    15 ) : (
    16 <Container>
    17 <FormContainer>
    18 <Img src={logo} alt="logo" />
    19 <p>
    20 <i>"Once upon a time, dinosaur use app to take a note"</i>
    21 </p>
    22 <Title>Login to DinoTes</Title>
    23 <InfoWrapper status={isSuccess} />
    24 <Formik
    25 initialValues={{ email: '', password: '' }}
    26 validate={(values) => {
    27 const errors = {};
    28 if (!values.email) {
    29 errors.email = 'Required';
    30 } else if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(values.email)) {
    31 errors.email = 'Invalid email address';
    32 }
    33 return errors;
    34 }}
    35 onSubmit={async (values, { setSubmitting }) => {
    36 try {
    37 const actionResult = await dispatch(login(values));
    38 const result = unwrapResult(actionResult);
    39 if (result) {
    40 setIsSuccess(true);
    41 window.location.reload();
    42 } else {
    43 setIsSuccess(false);
    44 }
    45 } catch (err) {
    46 setIsSuccess(false);
    47 } finally {
    48 dispatch(statusReset());
    49 setSubmitting(false);
    50 }
    51 }}
    52 >
    53 {({ values, errors, touched, handleChange, handleSubmit, isSubmitting }) => (
    54 <LoginForm onSubmit={handleSubmit}>
    55 <LoginFormGroup>
    56 <EmailField
    57 type="email"
    58 name="email"
    59 placeholder="Email Address"
    60 onChange={handleChange}
    61 value={values.email}
    62 />
    63 {errors.email && touched.email && errors.email}
    64 <PasswordField
    65 type="password"
    66 name="password"
    67 placeholder="Password"
    68 onChange={handleChange}
    69 value={values.password}
    70 />
    71 {errors.password && touched.password && errors.password}
    72 </LoginFormGroup>
    73 <Button type="submit" disabled={isSubmitting}>
    74 Login
    75 </Button>
    76 <p>
    77 Don't have an account?{' '}
    78 <Link to="/register">
    79 <b>Register</b>
    80 </Link>
    81 </p>
    82 </LoginForm>
    83 )}
    84 </Formik>
    85 </FormContainer>
    86 </Container>
    87 )}
    88 </>
    89 );
    90};
    91...

    Pada code di atas kita gunakan state isLoggedIn yang diambil dari slice menggunakan hook useSelector untuk melakukan conditional rendering, jika isLoggedIn bernilai true maka kita akan arahakan user ke halaman utama dari aplikasi DinoTes.

    Hasil Akhir: