Build an Instagram clone with AngularJS, Satellizer, Node.js and MongoDB
打开 login.html 把下面内容复制进去:
<div class="container">
<div class="center-form panel">
<div class="panel-body">
<h4 class="text-center"><i class="ion-log-in"></i> Log in</h4>
<form name="loginForm" ng-submit="emailLogin()" novalidate>
<div class="form-group has-feedback" ng-class="{ 'has-error': loginForm.email.$invalid && loginForm.email.$dirty }">
<input server-error class="form-control input-lg" type="text" name="email" ng-model="email" placeholder="Email" required autofocus>
<span class="ion-at form-control-feedback"></span>
<div class="help-block" ng-if="loginForm.email.$dirty" ng-messages="loginForm.email.$error">
<div ng-message="required">Please enter your email</div>
<div ng-message="server">{{errorMessage.email}}</div>
</div>
</div>
<div class="form-group has-feedback" ng-class="{ 'has-error': loginForm.password.$invalid && loginForm.password.$dirty }">
<input server-error class="form-control input-lg" type="password" name="password" ng-model="password" placeholder="Password" required>
<span class="ion-key form-control-feedback"></span>
<div class="help-block" ng-if="loginForm.password.$dirty" ng-messages="loginForm.password.$error">
<div ng-message="required">Please enter your password</div>
<div ng-message="server">{{errorMessage.password}}</div>
</div>
</div>
<button type="submit" class="btn btn-block btn-success" ng-disabled="loginForm.$invalid">Log in</button>
<br/>
<p class="text-center text-muted">
<small>Don't have an account yet? <a href="#/signup">Sign up</a></small>
</p>
<div class="signup-or-separator">
<h6 class="text">or</h6>
<hr>
</div>
</form>
<button class="btn btn-block btn-instagram" ng-click="instagramLogin()">
<i class="ion-social-instagram"></i> Sign in with Instagram
</button>
</div>
</div>
</div>
刷新浏览器你会看到如下登录页面:
第七行代码 ng-submit=“emailLogin()“ 将会执行在 LoginCtrl controller 里面的方法,我们将在下一节讲到,它和 jQuery 的 $('form').submit() 类似。
在第八行我们基于以下的两个 form 状态,来决定是否使用 Bootstrap 的 has-error class:
dirty
,也就是说,只有用户在 form 里面输入了点什么之后才会触发。注意:要了解更多关于 form 的状态,比如说 dirty,pristine, error, valid, invalid,请参考穆罕默德・阿扎木钉(Muhammad Azamuddin)的 The concepts of AngularJS Forms。还有,如果你不熟悉 Bootstrap 的表单认证以及反馈图标,请参考文档的 validation states。
为了能在字段下面显示异常信息,我们会用到在 AngularJS 1.3 中引入的 ng-messages 指令。如果你从来没有用过 ng-messages 指令,那么在开始使用之前你应该看看下面这些棒棒哒资源: Egghead.io Screencast 和 Year of Moo blog post。
ng-messages 里面唯一不清楚的地方应该只有ng-message=”server” 和 server-error 属性。后面的指令是之后我们要实现的,而前面的是一个 custom validator for ngMessages,用来显示 server-side
的输入验证异常,比如说 “The email or password you entered was incorrect”。
为什么需要 server-error 指令?因为我们需要在 keyDown 的时候,设置 $setValidity(‘server’, true) 来清理异常信息,比如说用户在 form 里面输入的时候。这是由于我设计了让登录按钮在 form 验证失败的时候处于非激活状态,所以当 $setValidity(‘server’, false) 的时候,form 会因为自定义验证 server 是无效的而一直处于验证失败状态,这时候我们就无法再次提交我们的 form 了。
这就是为什么我们需要能够重置 server 验证器的有效性。指令是唯一可以做到这点的地方。好吧,这仅仅是一个实现细节。给登录和注册设计一个好的用户体验不是这篇文章的主要目的。我们甚至可以用 401 状态码作为一个标记,当邮件地址或者密码错误的时候,我们就在 Angular 里面写死 “The email or password you entered was incorrect”。
下面是完成的工程的屏幕截图,看起来应该是用了一个数据库里没有的邮件地址来登录的结果。 “Incorrect email” 这个信息是从服务端发过来的,定义在 /auth/login 路由里面,我们会在 Step #13 实现它。
接下来,打开 login.js 把下面的复制进去:
<!-- lang: js -->
angular.module('Instagram')
.controller('LoginCtrl', function($scope, $window, $location, $rootScope, $auth) {
$scope.instagramLogin = function() {
$auth.authenticate('instagram')
.then(function(response) {
$window.localStorage.currentUser = JSON.stringify(response.data.user);
$rootScope.currentUser = JSON.parse($window.localStorage.currentUser);
})
.catch(function(response) {
console.log(response.data);
});
};
$scope.emailLogin = function() {
$auth.login({ email: $scope.email, password: $scope.password })
.then(function(response) {
$window.localStorage.currentUser = JSON.stringify(response.data.user);
$rootScope.currentUser = JSON.parse($window.localStorage.currentUser);
})
.catch(function(response) {
$scope.errorMessage = {};
angular.forEach(response.data.message, function(message, field) {
$scope.loginForm[field].$setValidity('server', false);
$scope.errorMessage[field] = response.data.message[field];
});
});
};
});
看名字就知道这两个方法是干什么的了,instagramLogin() 用来处理点击 Sign in with Instagram button 之后的事件,而 emailLogin() 用来处理用 form 里面的邮件地址和密码登录的事件。
Satellizer 有两种登录方式: authenticate 和 login。前一种是对应用 OAuth 1.0 和 OAuth 2.0 登录,而后一种对应的是用 email (或 username) 和 password 登录。login 和 authenticate 这种命名约定,只是我的个人爱好,为了区分两种不同的身份验证流程。
两种方式都会返回一个承诺,这就是为什么我们会用 .then 和 .catch 来处理成功和失败的响应。封装的方法 $auth.authenticate() 会打开一个弹出窗口,指向 authorizationEndpoint,比如说下面这个基于默认配置对象的动态结构里面的查询参数 https://accounts.google.com/o/oauth2/auth 。
<!-- lang: js -->
google: {
url: '/auth/google',
authorizationEndpoint: 'https://accounts.google.com/o/oauth2/auth',
redirectUri: window.location.origin || window.location.protocol + '//' + window.location.host,
scope: ['profile', 'email'],
scopePrefix: 'openid',
scopeDelimiter: ' ',
requiredUrlParams: ['scope'],
optionalUrlParams: ['display'],
display: 'popup',
type: '2.0',
popupOptions: { width: 452, height: 633 }
}
再比如 Facebook 登录,调用 $auth.authenticate(‘facebook’) 会打开弹窗,地址是类似下面这样 URL:
https://www.facebook.com/dialog/oauth?response_type=code&client_id=657854390977827&redirect_uri=https://satellizer.herokuapp.com/&display=popup&scope=email
url 第二行的属性是用来处理大多数认证流程的服务端点,我们将会在之后实现。记住,因为 Satellizer 使用的是 显式 grant type, 我们需要一个后端来处理认证。虽然 Satellizer 也支持单纯 client-side 认证,比如使用 pull request 157 的 隐式 grant type ,但是我不打算在这里讲它。这两种方式的 grant types 根本的区别,在于一种可以直接在客户端做认证而另外一种不能。
当你使用上面这幅图这样的显式 grant type 的时候,Instagram 会给你发放一个临时 authorization code,用来在服务端换 access_token 。这在纯客户端是做不到的。然后你可以用 access_token 来查询用户的个人信息或者执行该用户被允许执行的一些操作。如果你用过一些库,比如说 Passport.js,这就是所封装的一些处理。不过,在本教程中,我们将会自己去实现,这样你可以从头到尾理解 OAuth 2.0 流程是怎样工作的。
在隐式 grant type 中, Instagram 会正确的给你发行一个 access_token,而不是 authorization code。也就是说,你可以不通过服务端做验证,只需要客户端就足够了,比如说像 Facebook 和 Google JavaScript SDKs 做的那样。但这种方式的一个缺陷在于,当你拿到用户资料,决定把它们保存到你的数据库的时候,你怎么确定你拿到的这些用户资料和登录令牌是有效的?
你做不到,如果你不先做验证。这个验证各提供商都不一样,从超级简单的 (Facebook) 到相当困难的 (LinkedIn) 都有。如果你必须要在服务端去做这些超级烦人的验证的话,那你肯定会愿意一开始就是用显式 grant type,从供应商那拿 access token。对于单个 provider 来说应该还行,不过当你有了 3 或 4 个的时候,那就真的不好玩了。这种经验,我在开发 Satellizer 的早期已经爽够了。
注意: 亲爱的 LinkedIn,如果你有在听,请把你那烦人的 https 请求从 credentials_cookie: true 那删掉。我不希望我在本机开发应用的时候,仅仅为了做一个签名,我还得去弄一个 self-signed SSL 证书。还要让屌丝码农们,去操心要不要用 SSL。更好一点,你们可以换一个身份验证机制,比如类似 Facebook 和 Google 那样的。刨去安全因素,如果你让屌丝们蛋疼的话,他们肯定会不再想去撸你的 API。
如你在上面的截图中所见,在授权应用之后,弹窗会重定向回初始页面。这些在 satellizer.js 中都被设计成了自动处理,不需要用户去做额外的路由,也不用特地针对 redirectUrl 去做模板,就像我原来在 Hackathon Starter 中做的那样,是有多烦。一般情况下,关弹窗还是够快的,你基本上不会在这看到加载页面。不过如果你有更棒的解决方案,那你到我 GitHub 上开个讨论,或者直接提交一个 pull request。
第六行 的 scopePrefix 是 Google 的特例,请看下面的提示。它基本上也就是要求你要给 scope 字符串事先准备好 openid。
注意: StackExchange 的帖子 – Why use OpenID Connect instead of plain OAuth? 解释了 OAuth 和 OpenID 之间的差别(新标准)。
Satellizer 中有两种类型的 OAuth: “1.0” 和 “2.0”。当你通过 $authProvider.oauth2() 或者 $authProvider.oauth1() 添加一个新的 provider 时,类型会自动添加上去。基本上这些方法也就干了这样一件事。我还弄了一个 $authProvider.oauth() 方法,需要强制指定类型。用不用取决于个人爱好。
最后,参数 popupOptions 你可以用来指定自定义的弹出框口大小。每个供应商都有不同的 “理想” 窗口大小,最理想大小是刚好没有垂直和水平滚动条。你看到那些内置 provider 的数据,是我用 window.innerWidth 和 window.innerHeight 在 JavaScript Console 中手工找出来的。如果 popupOptions 没有被指定,长宽默认是 500 x 500 像素。
好了,希望这些能帮助你更好的理解 Satellizer 是怎样工作的。那么,当 $auth.authenticate() 执行之后,它会从服务器返回一个响应对象,包含了 JSON Web Token (JWT) 和用户对象。这个唯一 JWT token 是根据用户情报在服务端生成的。它是一个特殊字符串,看起来有点像下面这样的。
<!-- lang: js -->
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEyMzQ1Njc4OTAsIm5hbWUiOiJTYWhhdCJ9.WJs3pD-1m-at8fXSbaCfCwXal93_TLT5fF57lnRjYpQ
注意: 你可以在 jwt.io 上测试 JWT。还有我把 JSON Web Token 略称为 JWT token 是因为我觉得这样听起来更好听,尽管 token 这个词重复了两次。
之所以要把用户对象从服务器传过来,是因为通常 JWT token 只包括唯一用户 id 或者其他什么唯一标识符。这就要求我们要在页面上显示用户信息的时候又去服务端查询。比如,如果你需要在导航栏上显示用户的姓和名,你就要额外多查询一次,因为 JWT 只有用户的 id 而没有用户信息。
现在,让我们来理清一下。我们并不真的需要在 JWT token 里面保存用户 id。你把用户对象放到 JWT token 都想。如果这样确实有用的话你就这样用。不过你要记住,你的每个请求和中都要带上这个令牌,而且如果用户对象变大那么令牌也会变大。我们来看看在 Hackathon Starter 里面那个简单的用户对象生成的令牌有多大:
<!-- lang: js -->
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfX3YiOjEsIl9pZCI6eyIkb2lkIjoiNTM1YWIwNjcxZjJjMmJlYzAzODRlZjg
zIn0sImVtYWlsIjoic2FraGF0QGdtYWlsLmNvbSIsImdvb2dsZSI6IjEwMDQ3Mzc4MDc2NTEyMjc3NTMzMCIsImxpb
mtlZGluIjoiTlJxdUduVUpaRCIsInByb2ZpbGUiOnsibG9jYXRpb24iOiJHcmVhdGVyIE5ldyBZb3JrIENpdHkgQXJlYSI
sIm5hbWUiOiJTYWhhdCBZYWxrYWJvdiIsInBpY3R1cmUiOiJodHRwOi8vbS5jLmxua2QubGljZG4uY29tL21wci9tc
HJ4LzBfZ3hrRk9yYndmOTMyYU1UaGpnYjRPdDVJaXp2btJWVGgwbktaT1BGSnBQVFkxWW44QU1OSUsxUGNEN
HpsN2o4Mnl5UUpBcU1xTDdPVSIsIndlYnNpdGUiOiJodHRwOi8vd3d3LmxpbmtlZGluLmNvbS9pbi9zYWhhdHlhbG
thYm92In0sInJlc2V0UGFzc3dvcmRFeHBpcmVzIjp7IiRkYXRlIjoiMjAxNC0wNC0yNVQyMDozMjoxMi44MDdaIn0s
InJlc2V0UGFzc3dvcmRUb2tlbiI6ImY2NWJiN2IwNzAyYmZkZTY0ZTQyNDA0M2I4YWE1MDk1OWQ4M2NlMjAiLC
J0b2tlbnMiOlt7ImFjY2Vzc1Rva2VuIjoiQVFWWGdDNWlMTEdkUTl6eXJwUm1yT1NlTFJjQUFNQjllbjA4T0h1N2FVN
UdvMDN4VDJOdG1XeFVMS0Nod1RGWk9iaDN2ay11NkJxNnQwc1NzNnU2bS1TaDBHTzJ5ajJxaUJPdGk2aWFYdW
tnVnVscy1hcEIyTHVuTWhjc1BIbi0xWjZob3hGcmZkclNpVkFoak12Wm80enpXa21TZUktU2FTLXNydk40UVduTFp
pWklnbVEiLCJraW5kIjoibGlua2VkaW4ifSx7ImtpbmQiOiJnb29nbGUiLCJhY2Nlc3NUb2tlbiI6InlhMjkuMS5BQUR0
Tl9Xb0Q2Z0J5cmIwaG42S1dDVHZqYV92a2FKb2NXWEdrRnFvc0ZiQmYxUlRnaDZBVDZQVkxXdnF1RGtRQjdxX2
tYUnMifV19.RTbUuUcSlor15Sh-ZBYlRrGjZEFx5_H-hKWNMqP-eGM
这就是为什么我觉得把 JWT token 和用户对象分开传回来会比较好,然后把它存在 Local Storage 以备后用。这样你就不需要把个死胖 JWT token 在前后端传来传去了。
Satellizer 会把服务端的原始响应对象返回来。也就是说,如果你从服务端发送一个带有令牌和用户的 JSON 对象,我们在 authenticate() 和 login() 的 .then() 方法中的 response.data.user 拿到用户 。
你也许听很多人说过了,用 $rootScope 是个坏模式。个人经验,我从来没有遇到过什么问题,这也许是因为我从来没有做过什么大的 AngularJS 工程。不过总之,我们值得试试看在这里处理一些事情。
currentUser 对象包含了用户的情报,比如说 Instagram id, username, email 和 display name。由于我把它定义在 $rootScope 我们可以用 {{currentUser.username}} 在 Navbar 和 detail 页面。否则我们不得不在 NavbarCtrl 和 DetailCtrl 的 $scope 里面创建 currentUser ,以及那些我们以后要追加的,需要显示用户的情报的 controller 上都加上。不过你确实想用第二种方式,那么你尽管尝试一下吧。
instagramLogin() 和 emailLogin() 之间的最大不同是异常处理部分。实际上本教程中没做任何关于 Instagram 认证的异常处理。这里我给你演示异常处理代码:
<!-- lang: js -->
$scope.errorMessage = {};
angular.forEach(response.data.message, function(message, field) {
$scope.loginForm[field].$setValidity('server', false);
$scope.errorMessage[field] = response.data.message[field];
});
我们用了 Angular 的 forEach 方法来遍历 message 对象的 key。更直观的来看看从服务端返回的 JSON 响应:
<!-- lang: js -->
{ message: { email: 'Incorrect email' } }
在这种情况下,只有一个非有效值,也就是 email。所以在 第 3 行,它也就是给我们自定义验证器设置了 email 认证结果为 false。你知道的,这里有好些基本的 HTML 验证器,比如说 text, number, url, email, regex。好了,那么 server 验证器是专为我们的 email 和 password 字段所设计的。当服务端没有异常的时候它的值是 false,相反当我们的 email 和 password 无效的时候它会变成 true 。
第 4 行 我们在 $scope 设置了异常信息 “Incorrect email” ,这样我们可以在 login.html 页面访问它:
<!-- lang: js -->
<div ng-message="server">{{errorMessage.email}}</div>
我想,到这里我应该已经涵盖了登录页面的所有东西。不过在我们开始注册页面之前,让我们来实现一下 serverError 指令。
在 client 中创建一个新的文件夹,叫做 directives,然后在他里面新建一个 serverError.js 文件。
这是一个非常简单的 Angular 指令 – 它的主要目的是用来清除异常信息,比如说 Invalid Username or Password 和当用户开始重新输入的时候重置 form 的验证状态。否则,当你从服务端拿到一个异常信息之后,你就再也不能重新提交表单了,从我们当前的设计上来说。再说一次,好好看看土豆某头(Todd Motto)的 AngularJS guide,熟悉一下指令,或者看看我的 Introduction to AngularJS 。
<!-- lang: js -->
angular.module('Instagram')
.directive('serverError', function() {
return {
restrict: 'A',
require: 'ngModel',
link: function(scope, element, attrs, ctrl) {
element.on('keydown', function() {
ctrl.$setValidity('server', true)
});
}
}
});
最后,在 index.html 加上下面这行,把指令加载进来:
<!-- lang: js -->
<script src="directives/serverError.js"></script>
那么,接下来的是注册页面。