diff --git a/.analysis-options b/.analysis-options new file mode 100644 index 00000000..8dbd41c6 --- /dev/null +++ b/.analysis-options @@ -0,0 +1,3 @@ +analyzer: + exclude: + - .scripts-bin/**/*.dart \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7c280441..96609bf5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ .packages .project .pub/ +.scripts-bin/ build/ **/packages/ @@ -25,3 +26,5 @@ doc/api/ # Don't commit pubspec lock file # (Library packages only! Remove pattern if developing an application package) pubspec.lock + +log.txt \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..f016d94b --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Example server", + "type": "dart-cli", + "request": "launch", + "cwd": "${workspaceRoot}/example", + "debugSettings": "${command.debugSettings}", + "program": "${workspaceRoot}/example/server.dart", + "args": [] + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 6c2a46e5..6ce85ccb 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,4 @@ # auth_twitter angel_auth strategy for Twitter login. + +See the [example](example/server.dart); \ No newline at end of file diff --git a/example/server.dart b/example/server.dart new file mode 100644 index 00000000..1b1e792f --- /dev/null +++ b/example/server.dart @@ -0,0 +1,44 @@ +import 'dart:io'; +import 'package:angel_auth/angel_auth.dart'; +import 'package:angel_auth_twitter/angel_auth_twitter.dart'; +import 'package:angel_diagnostics/angel_diagnostics.dart'; +import 'package:angel_framework/angel_framework.dart'; + +const Map TWITTER_CONFIG = const { + 'callback': 'http://localhost:3000/auth/twitter/callback', + 'key': 'qlrBWXneoSYZKS2bT4TGHaNaV', + 'secret': 'n2oA0ZtR7TzYincpMYElRpyYovAQlhYizTkTm2x5QxjH6mLVyE' +}; + +main() async { + var app = new Angel(); + + var auth = new AngelAuth(jwtKey: 'AUTH_TWITTER_SECRET', allowCookie: false); + await app.configure(auth); + + auth.serializer = (user) async => user.id_str; + + auth.deserializer = (id) async { + // Of course, in a real app, you would fetch + // user data, but not here. + return {'id': id}; + }; + + auth.strategies.add(new TwitterStrategy(config: TWITTER_CONFIG)); + + app + ..get('/', auth.authenticate('twitter')) + ..get( + '/auth/twitter/callback', + auth.authenticate('twitter', + new AngelAuthOptions(callback: (req, res, jwt) { + return res.redirect('/home?token=$jwt'); + }))) + ..chain('auth').get('/home', (req, res) { + res + ..write('Hello, user #${req.user["id"]}!') + ..end(); + }); + + await new DiagnosticsServer(app, new File('log.txt')).startServer(null, 3000); +} diff --git a/lib/angel_auth_twitter.dart b/lib/angel_auth_twitter.dart new file mode 100644 index 00000000..663bfe8e --- /dev/null +++ b/lib/angel_auth_twitter.dart @@ -0,0 +1,157 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; +import 'dart:io'; +import 'package:angel_auth/angel_auth.dart'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:crypto/crypto.dart'; +import 'package:random_string/random_string.dart' as rs; + +const String _ENDPOINT = "https://api.twitter.com"; + +class TwitterStrategy extends AuthStrategy { + HttpClient _client = new HttpClient(); + final Map config; + + @override + String get name => 'twitter'; + + TwitterStrategy({this.config: const {}}); + + String _createSignature( + String method, String uriString, Map params, + {String tokenSecret: ""}) { + // Not only do we need to sort the parameters, but we need to URI-encode them as well. + var encoded = new SplayTreeMap(); + for (String key in params.keys) { + encoded[Uri.encodeComponent(key)] = Uri.encodeComponent(params[key]); + } + + String collectedParams = + encoded.keys.map((key) => "$key=${encoded[key]}").join("&"); + + String baseString = + "$method&${Uri.encodeComponent(uriString)}&${Uri.encodeComponent( + collectedParams)}"; + + String signingKey = "${Uri.encodeComponent( + config['secret'])}&$tokenSecret"; + + // After you create a base string and signing key, we need to hash this via HMAC-SHA1 + var hmac = new Hmac(sha1, signingKey.codeUnits); + + // The returned signature should be the resulting hash, Base64-encoded + return BASE64.encode(hmac.convert(baseString.codeUnits).bytes); + } + + Future _prepRequest(String path, + {String method: "GET", + Map data: const {}, + String accessToken, + String tokenSecret: ""}) async { + Map headers = new Map.from(data); + headers["oauth_version"] = "1.0"; + headers["oauth_consumer_key"] = config['key']; + + // The implementation of _randomString doesn't matter - just generate a 32-char + // alphanumeric string. + headers["oauth_nonce"] = rs.randomAlphaNumeric(32); + headers["oauth_signature_method"] = "HMAC-SHA1"; + headers["oauth_timestamp"] = + (new DateTime.now().millisecondsSinceEpoch / 1000).round().toString(); + + if (accessToken != null) { + headers["oauth_token"] = accessToken; + } + + var request = await _client.openUrl(method, Uri.parse("$_ENDPOINT$path")); + + headers['oauth_signature'] = _createSignature(method, + request.uri.toString().replaceAll("?${request.uri.query}", ""), headers, + tokenSecret: tokenSecret); + + var oauthString = headers.keys + .map((name) => '$name="${Uri.encodeComponent(headers[name])}"') + .join(", "); + + return request + ..headers.set(HttpHeaders.AUTHORIZATION, "OAuth $oauthString"); + } + + _mapifyRequest(HttpClientRequest request) async { + var rs = await request.close(); + var body = await rs.transform(UTF8.decoder).join(); + + if (rs.statusCode != HttpStatus.OK) { + throw new AngelHttpException.NotAuthenticated( + message: 'Twitter authentication error: $body'); + } + + var pairs = body.split('&'); + var data = {}; + + for (var pair in pairs) { + var index = pair.indexOf('='); + + if (index > -1) { + var key = pair.substring(0, index); + var value = Uri.decodeFull(pair.substring(index + 1)); + data[key] = value; + } + } + + return data; + } + + Future> createAccessToken( + String token, String verifier) async { + var request = await _prepRequest("/oauth/access_token", + method: "POST", data: {"verifier": verifier}, accessToken: token); + + request.headers.contentType = + ContentType.parse("application/x-www-form-urlencoded"); + request.writeln("oauth_verifier=$verifier"); + + return _mapifyRequest(request); + } + + Future> createRequestToken() async { + var request = await _prepRequest("/oauth/request_token", + method: "POST", data: {"oauth_callback": config['callback']}); + + // _mapifyRequest is a function that sends a request and parses its URL-encoded + // response into a Map. This detail is not important. + return await _mapifyRequest(request); + } + + @override + Future canLogout(RequestContext req, ResponseContext res) async => true; + @override + authenticate(RequestContext req, ResponseContext res, + [AngelAuthOptions options]) async { + if (options != null) { + return await authenticateCallback(req, res, options); + } else { + var result = await createRequestToken(); + String token = result['oauth_token']; + res.redirect("$_ENDPOINT/oauth/authenticate?oauth_token=$token"); + return false; + } + } + + Future authenticateCallback( + RequestContext req, ResponseContext res, AngelAuthOptions options) async { + var token = req.query['oauth_token']; + var verifier = req.query['oauth_verifier']; + var loginData = await createAccessToken(token, verifier); + + var oauthToken = loginData['oauth_token']; + var oauthTokenSecret = loginData['oauth_token_secret']; + + var request = await _prepRequest('/1.1/account/verify_credentials.json', + accessToken: oauthToken, tokenSecret: oauthTokenSecret); + var rs = await request.close(); + var body = await rs.transform(UTF8.decoder).join(); + return new Extensible()..properties.addAll(JSON.decode(body)); + } +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 00000000..6e358315 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,12 @@ +author: "Tobe O " +description: "angel_auth strategy for Twitter login." +environment: + sdk: ">=1.19.0" +homepage: "https://github.com/angel-dart/auth_twitter.git" +name: "angel_auth_twitter" +version: "1.0.0" +dependencies: + angel_auth: "^1.0.0-dev" + random_string: "^0.0.1" +dev_dependencies: + angel_diagnostics: "^1.0.0-dev+5"