跳至主要内容

[npm] Passport 筆記(Learn to Use Passport JS)

基本觀念

  • Strategies 要先定義好之後才能進入 routes
  • 當成功登入後可以得到 req.user 這個物件
  • 如果定義的 Strategy 驗證失敗,會在 callback 中回傳 err ,後續的路由不會被執行,並回傳 401 Unauthorized 的 response

在路由中使用設定好的 Passport Strategy

設定好的 Passport Strategy 可以直接在 routes 中使用:

在 routes 中使用

將定義好的 passport Strategy 當成 middleware (passport.authenticate('<strategyName>'))套用在 routes 中即可:

// ./routes/index.js

app.post(
'/login',
// passport as middleware
passport.authenticate('local'),

// routes handler
(req, res) => {
// 如果這個 function 有執行,表示通過驗證
// 在 req.user 中會回傳被認證的使用者
res.redirect(`/users/${req.user.username}`);
},
);

在預設的情況下,如果認證失敗,Passport 會回傳 401 Unauthorized 的狀態,後續的路由都不會在被處理;如果認證成功,則回觸發 next,並可以在 res.user 中拿到被認證的使用者。

若有需要也可以修改轉址的路徑:

app.get(
'/signin',
passport.authenticate('local', {
failureRedirect: '/signin',
failureFlash: true,
}),
(req, res) => res.redirect(`/users/${req.user.username}`),
);

Session

在成功登入後,Passport 會建立一個 login session。如果不需要可以把它停用(passport.authenticate('<strategyName>', { session: false })):

// ./routes/index.js

app.get('/api/users/me', passport.authenticate('basic', { session: false }), (req, res) => {
res.json({ id: req.user.id, username: req.user.username });
});

客制化 callback(常用)

如果內建的驗證請求不足夠使用,可以使用客制化的 callback 來處理,passport.authenticate('<strategyName>', callback<err, user, info>)

// ./routes/index.js

app.get('/login', function (req, res, next) {
// 在 routes 的 handler 中使用 passport.authenticate
passport.authenticate('local', function (err, user, info) {
if (err) {
return next(err);
}

// 如果找不到使用者
if (!user) {
return res.redirect('/login');
}

// 否則登入
req.logIn(user, function (err) {
if (err) {
return next(err);
}
return res.redirect('/users/' + user.username);
});
})(req, res, next);
});

在這個範例中,passport.authenticate('<strategyName>', callback<err, user, info>)是在 Express 中的路由中去執行,而不是當成 middleware 使用。因此,這是透過 closure 來在 callback 中取得 reqres

如果認證失敗,user 會被設成 false;如果例外發生;err 會被設定;info 則可以拿到 strategy 中 verify callback 所提供的更多訊息。

要注意的是,當使用客制化的 callback 時,需要自己透過 req.login() 來設置 session,並且回傳 response。

  • req.login('user', callback<err>) 來建立 session,若使用者登入成功,user 會被指定到 req.user(一般情況下,req.login() 會在 passport.authenticate 的 middleware 中被執行,但若是客制化的 callback 則要自己帶)。
  • req.logout() 會移除 req.user 這個屬性,並同時清除 login session(如果有的話)。

使用內建的驗證函式

app.post(
'/login',
passport.authenticate('local', {
successRedirect: '/',
failureRedirect: '/login',
session: false,
}),
);

設定 Passport Strategy

透過 passport.use() 可以設定 Strategies:

// ./middleware/passport

const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;

passport.use(
new LocalStrategy(
// 這是 verify callback
function (username, password, done) {
User.findOne({ username: username }, function (err, user) {
if (err) {
return done(err);
}

// 如果使用者不存在
if (!user) {
return done(null, false, { message: 'Incorrect username.' });
}

// 如果使用者密碼錯誤
if (!user.validPassword(password)) {
return done(null, false, { message: 'Incorrect password.' });
}

// 認證成功,回傳使用者資訊 user
return done(null, user);
});
},
),
);

驗證用的 callback

return done(<error|null>, <user|false>, {message: 'incorrect reason'})

透過 verify callback 來執行驗證後的結果。在 Passport 驗證一個 request 時,它會解析 request 中的登入資訊(credentials),接著以這些 credentials 當作參數來執行 verify callback(在上面的例子就是 username 和 password)。

有錯誤產生時

這裡的錯誤指的是伺服器的錯誤,這時候 err 會被設為非 null 的值;如果是驗證失敗(伺服器沒有錯誤)應該則此值要設定成null

return done(error);

驗證成功

如果 credentials 是有效的,那麼 verify callback 會執行 done,並提供受驗證後的使用者資訊(user):

return done(null, user);

驗證失敗

如果 credentials 無效,則執行 done 中代入第二個參數為 false,表示認證失敗:

return done(null, false);

也可以在第三個參數給認證失敗的理由:

return done(null, false, { message: 'Incorrect password.' });

在 Express 中使用

在 Express 中使用時,要在 middleware 中透過 passport.initialize() 來初始化 Passport。如果有使用 login session,則需要再使用 passport.session()

如果有使用 passport.session() 建議要放在 express.session() 來確保執行順序正確。

初始化 Passport

// ./app.js

const express = require('express')
const bodyParser = require('body-parser')
const cookieParser = require('cookie-parser')
const passport = require('./middleware/passport')

const index = require('./routes/index')
const app = express()

app.use(cookieParser())
app.use(express.bodyParser());
app.use(express.session({ secret: 'keyboard cat' }));

// 💡 passport
app.use(passport.initialize())
app.use(passport.session())

// routes
app.use('/', index)

// catch 404 and forward to error handler
app.use(function (req, res, next) {...})

// error handler
app.use(function (err, req, res, next) {...})

module.exports = app

解析 session

在一個典型的 web 應用中,帳號密碼這類的驗證訊息(credentials)只有在登入的時候會被傳送,如果驗證成功,會在 session 中留下記錄(例如 sessionId)並儲存 cookies 在瀏覽器中。後續的 request 都不會在帶有帳號密碼這些驗證資訊,而是透過 cookie 來辨認 session 。這麼做的好處是可以替 session 保留許多的空間

預設 Passport 會把整個 user 實例都存放在 session 中,但這麼做佔用了 session 許多不必要的空間。為解決這樣的問題,Passport 可以透過序列化(serialize)的方式,只保存 UserId 在 session 中,當有需要更多使用者資訊時,再透過反序列化(deserialize)的方式,根據 User ID 把整個 user 物件實例取出。

序列化(serialize)簡單來說就是把「物件」轉換成可被儲存在儲存空間的「資料」的這個過程,例如把 JavaScript 中的物件透過 JSON.stringify() 變成字串,就可以存放在儲存空間內;而反序列化則反過來是把「資料」轉換成程式碼中的「物件」,例如把 JSON 字串透過 JSON.parse() 轉換成物件。

在下面的例子中,只有 User ID(user.id)被序列化後存到 session,當後續 requests 近來時,則透過 ID 來找到原本 user 的資訊,並存回 req.user 中:

// ./middleware/passport
const passport = require('passport');

// 只把 UserID 保存在 session 中
passport.serializeUser((user, done) => {
done(null, user.id);
});

// 透過 UserID 找回原本的 User 資料,並存放在 req.user 中
passport.deserializeUser(async (id, done) => {
const user = await User.findByPk(id);
return done(null, user.toJSON());
});

Passport-LocalStrategy

  • 使用 passport.use(<strategy>) 來設定 strategy
  • 在 Strategy 中需要使用 verify callback,當 passport authenticate 接收到一個 request 時,它會去解析 request 中和認證有關的訊息(credentials),接著它會把這些 credentials 作為代入 verify callback 的參數:
    • 如果 credentials 有效(valid),則會呼叫 return done(null, user)
    • 如果 credentials 無效,則會呼叫並可顯示錯誤訊息 return done(null, false, {message: 'Wrong Password'})
    • 如果在過程中發生例外,例如連不上 db ,則會呼叫 return done(err)
  • 預設的情況下 LocalStrategy 會以 usernamepassword 當作驗證的欄位,如果有變更的話,可以透過usernameFieldpasswordField 來改變
// ./config/passport.js
// 使用 passport 的 LocalStrategy
const passport = require('passport');
const LocalStrategy = require('passport-local');
const bcrypt = require('bcryptjs');
const db = require('../models');

const { User } = db;

// setup passport strategy
passport.use(
new LocalStrategy(
// customize user field,預設使用 username 和 password 作為驗證的欄位
{
usernameField: 'email',
passportField: 'password',
passReqToCallback: true, // 如果需要在 verify callback 中取得 req
},
// customize verify callback
// 因為上面有註明 passReqToCallback: true,所以第一個參數會是 req
async (req, username, password, done) => {
try {
const user = await User.findOne({ where: { email: username } });
if (!user) {
return done(
null,
false,
// { message: 'Incorrect username.' }
req.flash('error_messages', '帳號或密碼輸入錯誤'),
);
}
if (!bcrypt.compareSync(password, user.password)) {
return done(
null,
false,
// { message: 'Incorrect password.' }
req.flash('error_messages', '帳號或密碼輸入錯誤'),
);
}
return done(null, user, req.flash('success_messages', '登入成功'));
} catch (error) {
return done(error);
}
},
),
);

登出(Logout)

若想要登出,只需要呼叫 req.logout() 即可:

// controller
app.get('/logout', (req, res) => {
req.logout(); // 由 passport 提供
res.redirect('/signin');
});

驗證權限(authenticate)

若要驗證使用者有無登入,可以使用 req.isAuthenticated() 這個方法,之後則可以透過 req.isAuthenticated 判斷使用者的登入狀態:

// controller
// 驗證使用者有無登入,可以使用 req.isAuthenticated() 這個方法
const authenticated = (req, res, next) => {
if (req.isAuthenticated()) {
return next();
}
return res.redirect('/signin');
};

const authenticatedAdmin = (req, res, next) => {
if (req.isAuthenticated()) {
if (req.user.isAdmin) {
return next();
}
return res.redirect('/');
}
return res.redirect('/signin');
};

app.get('/dashboard', authenticated, (req, res, next) => {...});
app.get('/admin/dashboard', authenticatedAdmin, (req, res, next) => {...});

JwtStrategy

  • new JwtStrategy(options, verify)
  • options
    • secretOrKey: 必填欄位
    • jwtFromRequest: 用來代入驗證的函式
    • verify 是一個 function verify(jwt_payload, done)
    • payload 是解碼後的 JWT payload
    • done 是一個 callback<error, user, info>
  • 找出 JWT(Extractor)的方式包含
    • fromHeader(header_name): 從指定的 http header name 中找 JWT
    • fromBodyField(field_name): 從 body 的欄位中找 JWT
    • fromUrlQueryParameter(param_name): 從 URL 的 query parameter 中找 JWT
    • fromAuthHeaderWithScheme(auth_scheme): 從 authorization header 中找 JWT
    • fromAuthHeader(): 以 scheme 'JWT' 尋找 authorization header(HTTP Header 的寫法要是{Authorization: JWT xxx.yyy.zzz}
    • fromExtractors([array of extractor functions]) 可以用陣列的方式列出所有上述想要使用的方法
const JwtStrategy = require('passport-jwt').Strategy;
const ExtractJwt = require('passport-jwt').ExtractJwt;

const opts = {
secreteOrKey: jwtConfig.secret,
jwtFromRequest: ExtractJwt.fromExtractors([
ExtractJwt.versionOneCompatibility({ authScheme: 'Bearer' }),
ExtractJwt.fromAuthHeader(),
]),
};

let jwtStrategy = new JwtStrategy(opts, function (payload, done) {
User.findById(payload.sub, function (err, user) {
if (err) return done(err);
if (!user) return done(null, false, { message: 'Wrong JWT Token' });
if (payload.aud !== user.email) return done(null, false, { message: 'Wrong JWT Token' });

const exp = payload.exp;
const nbf = payload.nbf;
const current = ~~(new Date().getTime() / 1000);
if (current > exp || current < nbf) {
return done(null, false, 'Token Expired');
}
return done(null, user);
});
});

passport.use('jwt', jwtStrategy);

參考資料

推薦閱讀