Xác thực đăng nhập bằng Passport – Kết nối các tài khoản
Bài viết này là một phần của series bài viết Xác thực đăng nhập bằng Passport:
Đây sẽ là bài viết cuối cùng trong series Xác thực đăng nhập bằng Passport. Chúng ta sẽ sử dụng tất các các phần đã làm ở những bài trước.
Kết nối tất cả các tài khoản với nhau
Bài viết này sẽ kết hợp những Passport Strategies để người dùng có thể có một tài khoản và kết nối tất cả khoản mạng xã hội của họ cùng với nhau.
[Hình ảnh]
Chúng ta sẽ phải thay đổi rất nhiều từ những phần đã thực hiện ở những bài trước để thực hiện tính năng này.
Dưới đây là các trường hợp có thể xảy ra khi chúng ta chỉ dùng một tài khoản có liên kết các tài khoản mạng xã hội.
Kịch bản
- Liên kết tài khoản: Kiểm tra một tài khoản đã tồn tại trong database, user có thể thêm các tài khoản mạng xã hội vào tài khoản của họ.
- Khởi tạo tài khoản: Nếu user chưa có trong db, thì khởi tạo một hồ sơ cho user.
- Hủy liên kết: Hủy liên kết với tài khoản mạng xã hội.
- Kết nối lại: Nếu một user hủy liên kết một tài khoản mạng xã hội, nhưng họ lại muốn kết nối lại.
Chúng ta sẽ đi từng trường hợp và cập nhật lại code của những phần trước.
Chúng ta sẽ code những gì?
Tôi sẽ làm việc với Local Strategy và Facebook Strategy để mô tả việc liên kết các tài khoản. Các loại tài khoản khác các bạn có thể làm tương tự.
Để thêm liên kết cho ứng dụng của chúng ta, những việc chúng ta sẽ làm là:
- Cập nhật Strategy của chúng ta.
- Thêm mới routes
- Cập nhật views cho việc Liên kết và Hủy liên kết
[Hình ảnh]
Giải thích User model
Khi nhìn vào cách chúng ta đã thiết kế User model, chúng ta đã cố tình thiết lập tất cả các tài khoản của người dùng trong một đối tượng.Điều này đảm bảo chúng ta có thể liên kết và hủy liên kết một các phù hợp. Chú ý rằng các tài khoản mạng xã hội sử dụng token và id trong khi tài khoản local của chúng ta sử dụng email và password.
// app/models/user.js ... var userSchema = mongoose.Schema({ local: { email: String, password: String }, facebook: { id: String, token: String, email: String, name: String }, twitter: { id: String, token: String, displayName: String, username: String }, google: { id: String, token: String, email: String, name: String } }); ...
Chúng ta đã thêm các thông tin như email, name, displayName và username cho một số tài khoản và hiển thị chúng riêng biệt cho mỗi tài khoản mà chúng ta có ở những bài trước.
Khi người dùng đã liên kết các tài khoản của họ, họ sẽ có một tài khoản trong db với đầy đủ thông tin của các trường.
[Hình ảnh]
Xác thực và ủy quyền
Khi lần đầu chúng ta thực hiện tạo Strategy, chúng ta sử dụng passport.authenticate. Đây là những gì chúng ta nên sử dụng khi xác thực user lần đầu tiên. Nhưng chúng ta sẽ làm gì khi user đã login? Họ sẽ vẫn được login và user của họ được lưu trữ trong session khi chúng ta muốn liên kết các tài khoản của họ với tài khoản hiện tại.
May mắn thay, Passport đã cung cấp một cách để kết nối một tài khoản của user. Họ cung cấp phương thức passport.authorize cho user đã được xác thực. Bạn có thể tìm hiểu thêm về các sử dụng ở đây Passport authorize docs.
Đầu tiên chúng ta sẽ cập nhật routes để xử lý việc xác thực, và tiếp theo chúng ta sẽ cập nhật Facebook Strategy để xử lý việc ủy quyền(authorization).
Routes
Đầu tiên, chúng ta sẽ làm việc với các route để xem các tài khoản có thể liên kết với nhau như thế nào. Ở những bài viết trước chúng ta đã tạo routes cho việc authentication. Giờ chúng ta sẽ tạo thêm một phần cho route dành cho việc authorization(ủy quyền). Khi chúng ta đã hoàn thành việc này, tiếp theo chúng ta sẽ thay đổi các Strategy cho phù hợp với các kịch bản.
// app/routes.js module.exports = function(app, passport) { // normal routes =============================================================== // show the home page (will also have our login links) // PROFILE SECTION ========================= // LOGOUT ============================== // ============================================================================= // AUTHENTICATE (FIRST LOGIN) ================================================== // ============================================================================= // locally -------------------------------- // LOGIN =============================== // show the login form // process the login form // SIGNUP ================================= // show the signup form // process the signup form // facebook ------------------------------- // send to facebook to do the authentication app.get('/auth/facebook', passport.authenticate('facebook', { scope : 'email' })); // handle the callback after facebook has authenticated the user app.get('/auth/facebook/callback', passport.authenticate('facebook', { successRedirect : '/profile', failureRedirect : '/' })); // twitter -------------------------------- // send to twitter to do the authentication // handle the callback after twitter has authenticated the user // google --------------------------------- // send to google to do the authentication // the callback after google has authenticated the user // ============================================================================= // AUTHORIZE (ALREADY LOGGED IN / CONNECTING OTHER SOCIAL ACCOUNT) ============= // ============================================================================= // locally -------------------------------- app.get('/connect/local', function(req, res) { res.render('connect-local.ejs', { message: req.flash('loginMessage') }); }); app.post('/connect/local', passport.authenticate('local-signup', { successRedirect : '/profile', // redirect to the secure profile section failureRedirect : '/connect/local', // redirect back to the signup page if there is an error failureFlash : true // allow flash messages })); // facebook ------------------------------- // send to facebook to do the authentication app.get('/connect/facebook', passport.authorize('facebook', { scope : 'email' })); // handle the callback after facebook has authorized the user app.get('/connect/facebook/callback', passport.authorize('facebook', { successRedirect : '/profile', failureRedirect : '/' })); // twitter -------------------------------- // send to twitter to do the authentication app.get('/connect/twitter', passport.authorize('twitter', { scope : 'email' })); // handle the callback after twitter has authorized the user app.get('/connect/twitter/callback', passport.authorize('twitter', { successRedirect : '/profile', failureRedirect : '/' })); // google --------------------------------- // send to google to do the authentication app.get('/connect/google', passport.authorize('google', { scope : ['profile', 'email'] })); // the callback after google has authorized the user app.get('/connect/google/callback', passport.authorize('google', { successRedirect : '/profile', failureRedirect : '/' })); }; // route middleware to ensure user is logged in function isLoggedIn(req, res, next) { if (req.isAuthenticated()) return next(); res.redirect('/'); }
Như bạn thấy, chúng ta có tất cả các route xác thực và các route để hiển thị trang index và trang profile của chúng ta. Đảm bảo bạn đã thêm các route để xác thực hiện việc xác thực giống như code của tôi.
Với những route chúng ta vừa mới tạo, hãy cập nhật lại Strategy để xử lý việc ủy quyền.
Cập nhật Strategy
Ở đây chúng ta sẽ chỉ làm ví dụ với Facebook và Local Strategy để minh họa việc chúng ta sẽ liên kết tất cả các tài khoản.
Khi sử dụng route passport.authorize
, thông tin user của chúng ta được lưu trữ trong session (từ khi họ đăng nhập thành công), thông tin này sẽ được chuyển qua Strategy. Bạn có thể nhìn vào phần code mới sẽ thay đổi để thấy rõ điều đó.
Tôi sẽ so sánh Strategy cũ và mới. Các bạn có thể đọc chú thích để hiểu đẩy đủ về những thay đổi đó.
Strategy cũ
// config/passport.js ... // ========================================================================= // FACEBOOK ================================================================ // ========================================================================= passport.use(new FacebookStrategy({ // điền thông tin để xác thực với Facebook. // những thông tin này đã được điền ở file auth.js clientID: configAuth.facebookAuth.clientID, clientSecret: configAuth.facebookAuth.clientSecret, callbackURL: configAuth.facebookAuth.callbackURL, profileFields: [ 'id', 'displayName', 'email', 'first_name', 'last_name', 'middle_name' ] }, // Facebook sẽ gửi lại chuối token và thông tin profile của user function (token, refreshToken, profile, done) { // asynchronous process.nextTick(function () { // tìm trong db xem có user nào đã sử dụng facebook id này chưa User.findOne({'facebook.id': profile.id}, function (err, user) { if (err) return done(err); // Nếu tìm thấy user, cho họ đăng nhập if (user) { return done(null, user); // user found, return that user } else { // nếu chưa có, tạo mới user var newUser = new User(); // lưu các thông tin cho user newUser.facebook.id = profile.id; newUser.facebook.token = token; newUser.facebook.name = profile.name.givenName + ' ' + profile.name.familyName; // bạn có thể log đối tượng profile để xem cấu trúc newUser.facebook.email = profile.emails[0].value; // fb có thể trả lại nhiều email, chúng ta lấy cái đầu tiền // lưu vào db newUser.save(function (err) { if (err) throw err; // nếu thành công, trả lại user return done(null, newUser); }); } }); }); }));
Bây giờ chúng ta muốn có khả năng để ủy quyền cho người dùng.
Facebook Strategy mới
... // ========================================================================= // FACEBOOK ================================================================ // ========================================================================= passport.use(new FacebookStrategy({ // điền thông tin để xác thực với Facebook. // những thông tin này đã được điền ở file auth.js clientID: configAuth.facebookAuth.clientID, clientSecret: configAuth.facebookAuth.clientSecret, callbackURL: configAuth.facebookAuth.callbackURL, passReqToCallback: true, // cho phép chúng ta chuyển đối tượng "req" từ route (cho phép kiểm tra người dùng //đã đăng nhập hay chưa) profileFields: [ 'id', 'displayName', 'email', 'first_name', 'last_name', 'middle_name' ] }, // Facebook sẽ gửi lại chuối token và thông tin profile của user function (req, token, refreshToken, profile, done) { // đối tượng req được chuyển từ route // asynchronous process.nextTick(function () { // kiểm tra người dùng đã đăng nhập hay chưa, cho đăng nhập như bình thường. if (!req.user) { // người dùng chưa đăng nhập // tìm trong db xem có user nào đã sử dụng facebook id này chưa User.findOne({'facebook.id': profile.id}, function (err, user) { if (err) return done(err); // Nếu tìm thấy user, cho họ đăng nhập if (user) { return done(null, user); // user found, return that user } else { // nếu chưa có, tạo mới user var newUser = new User(); // lưu các thông tin cho user newUser.facebook.id = profile.id; newUser.facebook.token = token; newUser.facebook.name = profile.name.givenName + ' ' + profile.name.familyName; // bạn có thể log đối tượng profile để xem cấu trúc newUser.facebook.email = profile.emails[0].value; // fb có thể trả lại nhiều email, chúng ta lấy cái đầu tiền // lưu vào db newUser.save(function (err) { if (err) throw err; // nếu thành công, trả lại user return done(null, newUser); }); } }); } else { // người dùng đã đăng nhập, chúng ta tiến hình kết nối tài khoản facebook var user = req.user; // lấy đối tượng người dùng trong session // cập nhật thông tin facebook cho tài khoản hiện tại user.facebook = { id: profile.id, token: token, name: profile.name.givenName + ' ' + profile.name.familyName, email: profile.emails[0].value }; // lưu lại thông tin người dùng user.save(function (err) { if (err) { throw err; } return done(null, user); }) } }); })); ...
Giờ chúng ta đã có xử lý cho việc liên kết các tài khoản cho user đã đăng nhập. Chúng ta vẫn có những xử lý như trước, giờ đây chúng ta chỉ việc kiểm tra rằng user đó đã đăng nhập hay chưa trước khi thực hiện các bước.
Kết nối các tài khoản
Bằng việc sử dụng Strategy đã cập nhật mới như ở trên, chúng ta sẽ tạo ra một user nếu user chưa đăng nhập, hoặc chúng ta sẽ thêm các thông tin facebook vào user đang đăng nhập.
Những Strategy khác: Phần code cập nhật cho Facebook Strategy sẽ tương tự cho Twitter và Google. Chỉ cần áp dụng code đó cho các Strategy khác, chúng sẽ hoạt động như chúng ta mong muốn. Tôi sẽ public toàn bộ mã nguồn của chương trình để các bạn có thể tham khảo.
Tới giờ chúng ta đã có routes, nó sẽ đẩy thông tin người dùng qua Facebook Strategy mới, hãy tạo giao diện người dùng cho phép user sử dụng route chúng ta vừa mới tạo.
Chúng ta sẽ cập nhật file profile.ejs
để hiển thị thông tin user và các nut để kết nối tài khoản ở trang profile.
<!-- views/profile.ejs --> <!doctype html> <html> <head> <title>Node Authentication</title> <link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.0.2/css/bootstrap.min.css"> <link rel="stylesheet" href="//netdna.bootstrapcdn.com/font-awesome/4.0.3/css/font-awesome.min.css"> <style> body { padding-top: 80px; word-wrap: break-word; } </style> </head> <body> <div class="container"> <div class="page-header text-center"> <h1><span class="fa fa-anchor"></span> Profile Page</h1> <a href="/logout" class="btn btn-default btn-sm">Logout</a> </div> <div class="row"> <!-- LOCAL INFORMATION --> <div class="col-sm-6"> <div class="well"> <h3><span class="fa fa-user"></span> Local</h3> <% if (user.local.email) { %> <p> <strong>id</strong>: <%= user._id %><br> <strong>email</strong>: <%= user.local.email %><br> <strong>password</strong>: <%= user.local.password %> </p> <a href="/unlink/local" class="btn btn-default">Unlink</a> <% } else { %> <a href="/connect/local" class="btn btn-default">Connect Local</a> <% } %> </div> </div> <!-- FACEBOOK INFORMATION --> <div class="col-sm-6"> <div class="well"> <h3 class="text-primary"><span class="fa fa-facebook"></span> Facebook</h3> <!-- kiểm tra user có chứa token hay không(user chứa token là user được đăng nhập bằng tài khoản mạng xã hộ) --> <% if (user.facebook.token){ %> <p> <strong>id</strong>: <%= user.facebook.id %><br> <strong>token</strong>: <%= user.facebook.token %><br> <strong>email</strong>: <%= user.facebook.email %><br> <strong>name</strong>: <%= user.facebook.name %> </p> <a href="/unlink/facebook" class="btn btn-primary">Unlink</a> <% } else { %> <a href="/connect/facebook" class="btn btn-primary">Connect Facebook</a> <% } %> </div> </div> <!-- TWITTER INFORMATION --> <div class="col-sm-6"> <div class="well"> <h3 class="text-info"><span class="fa fa-twitter"></span> Twitter</h3> <% if (user.twitter.token) { %> <p> <strong>id</strong>: <%= user.twitter.id %><br> <strong>token</strong>: <%= user.twitter.token %><br> <strong>username</strong>: <%= user.twitter.username %><br> <strong>displayName</strong>: <%= user.twitter.displayName %> </p> <a href="/unlink/twitter" class="btn btn-info">Unlink</a> <% } else { %> <a href="/connect/twitter" class="btn btn-info">Connect Twitter</a> <% } %> </div> </div> <!-- GOOGLE INFORMATION --> <div class="col-sm-6"> <div class="well"> <h3 class="text-danger"><span class="fa fa-google-plus"></span> Google</h3> <% if (user.google.token) { %> <p> <strong>id</strong>: <%= user.google.id %><br> <strong>token</strong>: <%= user.google.token %><br> <strong>email</strong>: <%= user.google.email %><br> <strong>name</strong>: <%= user.google.name %> </p> <a href="/unlink/google" class="btn btn-danger">Unlink</a> <% } else { %> <a href="/connect/google" class="btn btn-danger">Connect Google</a> <% } %> </div> </div> </div> </div> </body> </html>
Giờ đây chúng ta có thể kết nối các tài khoản thông qua các phương thức login khác nhau. Sau khi user đang nhập bằng một phương thức, bằng hồ sơ của họ chúng ta sẽ kiểm tra user đã liên kết tới các tài khoản khác hay chưa.
Nếu user chưa được liên kết, nó sẽ hiển thị nút Kết nối. Nếu đã có liên kết nó sẽ hiển thị thông tin user và nút Hủy kết nối.
Hãy nhớ rằng chúng ta đẩy thông tin của user ra trang profile là từ file routes.js.
Kết nối Local
Những tài khoản mạng xã hội rất dễ dàng để chúng ta thực hiện việc liên kết (bằng phương thức passport.authorize). Vấn đề hiện tại xảy ra nếu người dùng muốn liên kết tời một tài khoản local. Vấn để xảy ra vì user cần thấy một trang để hoàn thành việc xác thực bằng tài khoản local (như trang đăng ký).
Chúng ta đã tạo route cho việc hiển thị form kết nối local (trong file routes.js
: (app.get('connect/local'))
). Tất cả những gì chúng ta cần là tạo một view để hiển thị nó lên.
Tạo mới một file view theo đường dẫn: views/connect-local.ejs
<!-- views/connect-local.ejs --> <!doctype html> <html> <head> <title>Node Authentication</title> <link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.0.2/css/bootstrap.min.css"> <link rel="stylesheet" href="//netdna.bootstrapcdn.com/font-awesome/4.0.3/css/font-awesome.min.css"> <style> body { padding-top: 80px; } </style> </head> <body> <div class="container"> <div class="col-sm-6 col-sm-offset-3"> <h1><span class="fa fa-user"></span> Add Local Account</h1> <% if (message.length > 0) { %> <div class="alert alert-danger"><%= message %></div> <% } %> <!-- LOGIN FORM --> <form action="/connect/local" method="post"> <div class="form-group"> <label>Email</label> <input type="text" class="form-control" name="email"> </div> <div class="form-group"> <label>Password</label> <input type="password" class="form-control" name="password"> </div> <button type="submit" class="btn btn-warning btn-lg">Add Local</button> </form> <hr> <p><a href="/profile">Go back to profile</a></p> </div> </div> </body> </html>
Nó sẽ trông giống form ở trang signup.ejs, chúng ta chỉ thay đổi thuộc tính action của form html.
Bây giờ mỗi khi bạn muốn kết nối với tài khoản local, bạn sẽ được chuyển tới trang này và khi bạn submit, nó sẽ được xử lý ở Local Strategy. Ở đó thực hiện việc liên kết các tài khoản.
Kết quả việc liên kết các tài khoản
Chỉ bằng việc thay đổi các routes và cập nhật lại Strategy, ứng dụng của chúng ta có thể liên kết các tài khoản lại với nhau! Chúng ta sẽ xem một user đã liên kết với các tài khoản mạng xã hội trong database:
Hủy liên kết
Việc kết nối các tài khoản khá đơn giản, vậy còn việc hủy bỏ các liên kết, chúng ta sẽ xem xét trường hợp người dùng muốn hủy liên kết tới tài khoản fb của họ.
Để phục vụ mục đích của chúng ta, khi một người dùng muốn hủy một liên kết, chúng ta sẽ chỉ xóa bỏ token của họ.Chúng ta sẽ giữ lại id (id của tài khoản mạng xã hội) trong db để dành cho trường hợp họ muốn liên kết trở lại.
Chúng ta có thể làm mọi việc ở file routes. Bạn được khuyên tạo ra các file controller and xử lý mọi logic ở đó. Khi cần bạn chỉ cần gọi controller từ file routes. Để cho đơn giản tôi sẽ viết logic ngay trong file routes.
Giờ chúng ta thêm những route để hủy liên kết:
// app/routes.js ... // local ----------------------------------- app.get('/unlink/local', function(req, res) { var user = req.user; user.local.email = undefined; user.local.password = undefined; user.save(function(err) { res.redirect('/profile'); }); }); // facebook ------------------------------- app.get('/unlink/facebook', function(req, res) { var user = req.user; user.facebook.token = undefined; user.save(function(err) { res.redirect('/profile'); }); }); // twitter -------------------------------- app.get('/unlink/twitter', function(req, res) { var user = req.user; user.twitter.token = undefined; user.save(function(err) { res.redirect('/profile'); }); }); // google --------------------------------- app.get('/unlink/google', function(req, res) { var user = req.user; user.google.token = undefined; user.save(function(err) { res.redirect('/profile'); }); }); ...
Chúng ta đã tạo ra các đường dẫn (nút) cho việc hủy liên kết, giờ nó đã hoạt động với các route mới.
Liên kết lại tài khoản đã hủy liên kết
Sau khi chúng ta hủy liên kết một tài khoản, id của họ vẫn ở trong db. Chính vì vậy, khi user đăng nhập hoặc kết nối lại, chúng ta chỉ cần kiểm tra xem id đã tồn tại hay chưa và cập nhật lại thông tin cho tài khoản.
Chúng ta sẽ xử lý trong Facebook Strategy(các strategy khác xử lý tương tự)
// config/passport.js ... // ========================================================================= // FACEBOOK ================================================================ // ========================================================================= passport.use(new FacebookStrategy({ // điền thông tin để xác thực với Facebook. // những thông tin này đã được điền ở file auth.js clientID: configAuth.facebookAuth.clientID, clientSecret: configAuth.facebookAuth.clientSecret, callbackURL: configAuth.facebookAuth.callbackURL, passReqToCallback: true, // cho phép chúng ta chuyển đối tượng "req" từ route (cho phép kiểm tra người dùng //đã đăng nhập hay chưa) profileFields: [ 'id', 'displayName', 'email', 'first_name', 'last_name', 'middle_name' ] }, // Facebook sẽ gửi lại chuối token và thông tin profile của user function (req, token, refreshToken, profile, done) { // đối tượng req được chuyển từ route // asynchronous process.nextTick(function () { // kiểm tra người dùng đã đăng nhập hay chưa, cho đăng nhập như bình thường. if (!req.user) { // người dùng chưa đăng nhập // tìm trong db xem có user nào đã sử dụng facebook id này chưa User.findOne({'facebook.id': profile.id}, function (err, user) { if (err) return done(err); // Nếu tìm thấy user, cho họ đăng nhập if (user) { if (!user.facebook.token) { user.facebook = { id: profile.id, name: profile.name.givenName + ' ' + profile.name.familyName, email: profile.emails[0].value }; } user.facebook.token = token; // alway update user token to user service fb provider user.save(function(err) { if (err) throw err; return done(null, user); }); } else { // nếu chưa có, tạo mới user var newUser = new User(); // lưu các thông tin cho user newUser.facebook.id = profile.id; newUser.facebook.token = token; newUser.facebook.name = profile.name.givenName + ' ' + profile.name.familyName; // bạn có thể log đối tượng profile để xem cấu trúc newUser.facebook.email = profile.emails[0].value; // fb có thể trả lại nhiều email, chúng ta lấy cái đầu tiền // lưu vào db newUser.save(function (err) { if (err) throw err; // nếu thành công, trả lại user return done(null, newUser); }); } }); } else { // người dùng đã đăng nhập, chúng ta tiến hình kết nối tài khoản facebook var user = req.user; // lấy đối tượng người dùng trong session // cập nhật thông tin facebook cho tài khoản hiện tại user.facebook = { id: profile.id, token: token, name: profile.name.givenName + ' ' + profile.name.familyName, email: profile.emails[0].value }; // lưu lại thông tin người dùng user.save(function (err) { if (err) { throw err; } return done(null, user); }) } }); })); ...
Chúc mừng bạn đã hoàn thành ứng dụng của mình!
Source code
Mã nguồn của toàn bộ các bài viết thuộc series này: Github