From 008a62e967868fc62ff1d3c7633d84463db0f5a0 Mon Sep 17 00:00:00 2001 From: Jonas Franz Date: Tue, 15 May 2018 22:37:20 +0200 Subject: [PATCH 01/11] Add support for U2F Signed-off-by: Jonas Franz --- custom/conf/app.ini.sample | 10 +- models/error.go | 22 + models/migrations/migrations.go | 2 + models/migrations/v65.go | 19 + models/models.go | 1 + models/u2f.go | 111 +++ modules/auth/user_form.go | 20 + modules/setting/setting.go | 8 + options/locale/locale_en-US.ini | 12 + public/js/index.js | 92 ++- public/vendor/librejs.html | 5 + public/vendor/plugins/u2f/u2f-api.js | 748 +++++++++++++++++++ routers/routes/routes.go | 14 + routers/user/auth.go | 109 ++- routers/user/setting.go | 99 ++- templates/base/footer.tmpl | 3 + templates/user/auth/u2f.tmpl | 21 + templates/user/settings/security.tmpl | 1 + templates/user/settings/security_openid.tmpl | 2 +- templates/user/settings/security_u2f.tmpl | 54 ++ vendor/vendor.json | 6 + 21 files changed, 1347 insertions(+), 12 deletions(-) create mode 100644 models/migrations/v65.go create mode 100644 models/u2f.go create mode 100644 public/vendor/plugins/u2f/u2f-api.js create mode 100644 templates/user/auth/u2f.tmpl create mode 100644 templates/user/settings/security_u2f.tmpl diff --git a/custom/conf/app.ini.sample b/custom/conf/app.ini.sample index 68f144c089b37..5b858dbb2d193 100644 --- a/custom/conf/app.ini.sample +++ b/custom/conf/app.ini.sample @@ -288,7 +288,7 @@ RESET_PASSWD_CODE_LIVE_MINUTES = 180 REGISTER_EMAIL_CONFIRM = false ; Disallow registration, only allow admins to create accounts. DISABLE_REGISTRATION = false -; Allow registration only using third part services, it works only when DISABLE_REGISTRATION is false +; Allow registration only using third part services, it works only when DISABLE_REGISTRATION is false ALLOW_ONLY_EXTERNAL_REGISTRATION = false ; User must sign in to view anything. REQUIRE_SIGNIN_VIEW = false @@ -570,6 +570,14 @@ MAX_RESPONSE_ITEMS = 50 LANGS = en-US,zh-CN,zh-HK,zh-TW,de-DE,fr-FR,nl-NL,lv-LV,ru-RU,ja-JP,es-ES,pt-BR,pl-PL,bg-BG,it-IT,fi-FI,tr-TR,cs-CZ,sr-SP,sv-SE,ko-KR NAMES = English,简体中文,繁體中文(香港),繁體中文(台灣),Deutsch,français,Nederlands,latviešu,русский,日本語,español,português do Brasil,polski,български,italiano,suomi,Türkçe,čeština,српски,svenska,한국어 +[U2F] +; Two Factor authentication with security keys +; https://developers.yubico.com/U2F/App_ID.html +APP_ID = https://example.com +; Comma seperated list of truisted facets +TRUSTED_FACETS = https://localhost:3000,https://192.168.178.18:3000 + + ; Used for datetimepicker [i18n.datelang] en-US = en diff --git a/models/error.go b/models/error.go index cdb18d23ce5cb..316f8c34bd5d8 100644 --- a/models/error.go +++ b/models/error.go @@ -1237,3 +1237,25 @@ func IsErrExternalLoginUserNotExist(err error) bool { func (err ErrExternalLoginUserNotExist) Error() string { return fmt.Sprintf("external login user link does not exists [userID: %d, loginSourceID: %d]", err.UserID, err.LoginSourceID) } + +// ____ ________________________________ .__ __ __ .__ +// | | \_____ \_ _____/\______ \ ____ ____ |__| _______/ |_____________ _/ |_|__| ____ ____ +// | | // ____/| __) | _// __ \ / ___\| |/ ___/\ __\_ __ \__ \\ __\ |/ _ \ / \ +// | | // \| \ | | \ ___// /_/ > |\___ \ | | | | \// __ \| | | ( <_> ) | \ +// |______/ \_______ \___ / |____|_ /\___ >___ /|__/____ > |__| |__| (____ /__| |__|\____/|___| / +// \/ \/ \/ \/_____/ \/ \/ \/ + +// ErrU2FRegistrationNotExist represents a "ErrU2FRegistrationNotExist" kind of error. +type ErrU2FRegistrationNotExist struct { + ID int64 +} + +func (err ErrU2FRegistrationNotExist) Error() string { + return fmt.Sprintf("U2F registration does not exist [id: %d]", err.ID) +} + +// IsErrU2FRegistrationNotExist checks if an error is a ErrU2FRegistrationNotExist. +func IsErrU2FRegistrationNotExist(err error) bool { + _, ok := err.(ErrU2FRegistrationNotExist) + return ok +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index e85da8de79a40..7c90f1eb1fe23 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -182,6 +182,8 @@ var migrations = []Migration{ NewMigration("add language column for user setting", addLanguageSetting), // v64 -> v65 NewMigration("add multiple assignees", addMultipleAssignees), + // v65 -> v66 + NewMigration("add u2f", addU2FReg), } // Migrate database to current version diff --git a/models/migrations/v65.go b/models/migrations/v65.go new file mode 100644 index 0000000000000..f73e632877119 --- /dev/null +++ b/models/migrations/v65.go @@ -0,0 +1,19 @@ +package migrations + +import ( + "code.gitea.io/gitea/modules/util" + "github.com/go-xorm/xorm" +) + +func addU2FReg(x *xorm.Engine) error { + type U2FRegistration struct { + ID int64 `xorm:"pk autoincr"` + Name string + UserID int64 `xorm:"INDEX"` + Raw []byte + Counter uint32 + CreatedUnix util.TimeStamp `xorm:"INDEX created"` + UpdatedUnix util.TimeStamp `xorm:"INDEX updated"` + } + return x.Sync2(&U2FRegistration{}) +} diff --git a/models/models.go b/models/models.go index 9213cd3b790e9..ddf784deeebcb 100644 --- a/models/models.go +++ b/models/models.go @@ -120,6 +120,7 @@ func init() { new(LFSLock), new(Reaction), new(IssueAssignees), + new(U2FRegistration), ) gonicNames := []string{"SSL", "UID"} diff --git a/models/u2f.go b/models/u2f.go new file mode 100644 index 0000000000000..a015a75d0e2c2 --- /dev/null +++ b/models/u2f.go @@ -0,0 +1,111 @@ +package models + +import ( + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" + + "github.com/tstranex/u2f" +) + +// U2FRegistration represents the registration data and counter of a security key +type U2FRegistration struct { + ID int64 `xorm:"pk autoincr"` + Name string + UserID int64 `xorm:"INDEX"` + Raw []byte + Counter uint32 + CreatedUnix util.TimeStamp `xorm:"INDEX created"` + UpdatedUnix util.TimeStamp `xorm:"INDEX updated"` +} + +// Parse will convert the db entry U2FRegistration to an u2f.Registration struct +func (reg *U2FRegistration) Parse() (*u2f.Registration, error) { + r := new(u2f.Registration) + return r, r.UnmarshalBinary(reg.Raw) +} + +func (reg *U2FRegistration) updateCounter(e Engine) error { + _, err := e.ID(reg.ID).Cols("counter").Update(reg) + return err +} + +// UpdateCounter will update the database value of counter +func (reg *U2FRegistration) UpdateCounter() error { + return reg.updateCounter(x) +} + +// U2FRegistrationList is a list of *U2FRegistration +type U2FRegistrationList []*U2FRegistration + +// ToRegistrations will convert all U2FRegistrations to u2f.Registrations +func (list U2FRegistrationList) ToRegistrations() []u2f.Registration { + regs := make([]u2f.Registration, len(list)) + for _, reg := range list { + r, err := reg.Parse() + if err != nil { + log.Fatal(4, "parsing u2f registration: %v", err) + continue + } + regs = append(regs, *r) + } + + return regs +} + +func getU2FRegistrationsByUID(e Engine, uid int64) (U2FRegistrationList, error) { + regs := make(U2FRegistrationList, 0) + return regs, e.Where("user_id = ?", uid).Find(®s) +} + +// GetU2FRegistrationByID returns U2F registration by id +func GetU2FRegistrationByID(id int64) (*U2FRegistration, error) { + return getU2FRegistrationByID(x, id) +} + +func getU2FRegistrationByID(e Engine, id int64) (*U2FRegistration, error) { + reg := new(U2FRegistration) + if found, err := e.ID(id).Get(reg); err != nil { + return nil, err + } else if !found { + return nil, ErrU2FRegistrationNotExist{ID: id} + } + return reg, nil +} + +// GetU2FRegistrationsByUID returns all U2F registrations of the given user +func GetU2FRegistrationsByUID(uid int64) (U2FRegistrationList, error) { + return getU2FRegistrationsByUID(x, uid) +} + +func createRegistration(e Engine, user *User, name string, reg *u2f.Registration) (*U2FRegistration, error) { + raw, err := reg.MarshalBinary() + if err != nil { + return nil, err + } + r := &U2FRegistration{ + UserID: user.ID, + Name: name, + Counter: 0, + Raw: raw, + } + _, err = e.InsertOne(r) + if err != nil { + return nil, err + } + return r, nil +} + +// CreateRegistration will create a new U2FRegistration from the given Registration +func CreateRegistration(user *User, name string, reg *u2f.Registration) (*U2FRegistration, error) { + return createRegistration(x, user, name, reg) +} + +// DeleteRegistration will delete U2FRegistration +func DeleteRegistration(reg *U2FRegistration) error { + return deleteRegistration(x, reg) +} + +func deleteRegistration(e Engine, reg *U2FRegistration) error { + _, err := e.Delete(reg) + return err +} diff --git a/modules/auth/user_form.go b/modules/auth/user_form.go index 5906abcd1d2a9..482238b7d153f 100644 --- a/modules/auth/user_form.go +++ b/modules/auth/user_form.go @@ -211,3 +211,23 @@ type TwoFactorScratchAuthForm struct { func (f *TwoFactorScratchAuthForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors { return validate(errs, ctx.Data, f, ctx.Locale) } + +// U2FRegistrationForm for reserving an U2F name +type U2FRegistrationForm struct { + Name string `binding:"Required"` +} + +// Validate valideates the fields +func (f *U2FRegistrationForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors { + return validate(errs, ctx.Data, f, ctx.Locale) +} + +// U2FDeleteForm for deleting U2F keys +type U2FDeleteForm struct { + ID int64 `binding:"Required"` +} + +// Validate valideates the fields +func (f *U2FDeleteForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors { + return validate(errs, ctx.Data, f, ctx.Locale) +} diff --git a/modules/setting/setting.go b/modules/setting/setting.go index 9f20d955cceb5..adf4bb74fd299 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -521,6 +521,11 @@ var ( MaxResponseItems: 50, } + U2F = struct { + AppID string + TrustedFacets []string + }{} + // I18n settings Langs []string Names []string @@ -1135,6 +1140,9 @@ func NewContext() { IsInputFile: sec.Key("IS_INPUT_FILE").MustBool(false), }) } + sec = Cfg.Section("U2F") + U2F.TrustedFacets, _ = shellquote.Split(sec.Key("TRUSTED_FACETS").MustString(strings.TrimRight(AppURL, "/"))) + U2F.AppID = sec.Key("APP_ID").MustString(strings.TrimRight(AppURL, "/")) } // Service settings diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 8ef00566081a9..75bccaab57f5c 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -31,6 +31,11 @@ twofa = Two-Factor Authentication twofa_scratch = Two-Factor Scratch Code passcode = Passcode +u2f_insert_key = Insert your security key +u2f_sign_in = Press the button on your security key. If you can't find a button, re-insert it. +u2f_press_button = Please press the button on your security key… +u2f_use_twofa = Use a two-factor code from your phone + repository = Repository organization = Organization mirror = Mirror @@ -320,6 +325,7 @@ twofa = Two-Factor Authentication account_link = Linked Accounts organization = Organizations uid = Uid +u2f = Security Keys public_profile = Public Profile profile_desc = Your email address will be used for notifications and other operations. @@ -449,6 +455,12 @@ then_enter_passcode = And enter the passcode shown in the application: passcode_invalid = The passcode is incorrect. Try again. twofa_enrolled = Your account has been enrolled into two-factor authentication. Store your scratch token (%s) in a safe place as it is only shown once! +u2f_desc = Security keys are hardware devices containing cryptograhic keys. They could be used for two factor authentication. The security key must support the FIDO U2F standard. +u2f_require_twofa = Two-Factor-Authentication must be enrolled in order to use security keys. +u2f_register_key = Add Security Key +u2f_nickname = Nickname +u2f_press_button = Press the button on your security key to register it. + manage_account_links = Manage Linked Accounts manage_account_links_desc = These external accounts are linked to your Gitea account. account_links_not_available = There are currently no external accounts linked to your Gitea account. diff --git a/public/js/index.js b/public/js/index.js index e826c2f3f3d60..98f353604ef8c 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -1432,6 +1432,94 @@ function initCodeView() { } } +function initU2FAuth() { + if($('#wait-for-key').length === 0) { + return + } + + $.getJSON('/user/u2f/challenge').success(function(req) { + console.log(req); + u2f.sign(req.appId, req.challenge, req.registeredKeys, u2fSigned, 30); + }); +} +function u2fSigned(resp) { + console.log("u2fSigned", resp); + $.ajax({ + url:'/user/u2f/sign', + type:"POST", + headers: {"X-Csrf-Token": csrf}, + data: JSON.stringify(resp), + contentType:"application/json; charset=utf-8", + }).done(function(res){ + console.log(res); + window.location.replace(res); + }).fail(function (xhr, textStatus) { + // TODO error handling + console.log(xhr); + }); +} + +function u2fRegistered(resp) { + if (checkError(resp)) { + return; + } + if(u2f !== undefined){ + $.ajax({ + url:'/user/settings/security/u2f/register', + type:"POST", + headers: {"X-Csrf-Token": csrf}, + data: JSON.stringify(resp), + contentType:"application/json; charset=utf-8", + success: function(){ + window.location.reload(); + }, + fail: function (xhr, textStatus) { + console.log(xhr); + } + }); + } +} + +// TODO better error handling +function checkError(resp) { + if (!('errorCode' in resp)) { + return false; + } + if (resp.errorCode === u2f.ErrorCodes['OK']) { + return false; + } + var msg = 'U2F error code ' + resp.errorCode; + for (var code_name in u2f.ErrorCodes) { + if (u2f.ErrorCodes[code_name] === resp.errorCode) { + msg += ' (' + code_name + ')'; + } + } + if (resp.errorMessage) { + msg += ': ' + resp.errorMessage; + } + console.log(msg); + return true; +} +function initU2FRegister() { + $('#register-security-key').on('click', function(e) { + e.preventDefault(); + $.post("/user/settings/security/u2f/request_register", { + "_csrf": csrf, + "name": $('#nickname').val() + }).success(function(req) { + $('#register-device').modal('show'); + if(req.registeredKeys === null) { + req.registeredKeys = [] + } + u2f.register(req.appId, req.registerRequests, req.registeredKeys, u2fRegistered, 30); + }).fail(function(xhr, status, error) { + if(xhr.status === 409) { + $("#nickname").closest("div.field").addClass("error"); + } + }); + }) +} + $(document).ready(function () { csrf = $('meta[name=_csrf]').attr("content"); suburl = $('meta[name=_suburl]').attr("content"); @@ -1643,6 +1731,8 @@ $(document).ready(function () { initCtrlEnterSubmit(); initNavbarContentToggle(); initTopicbar(); + initU2FAuth(); + initU2FRegister(); // Repo clone url. if ($('#repo-clone-url').length > 0) { @@ -2201,7 +2291,7 @@ function initTopicbar() { return } var topicArray = topics.split(","); - + var last = viewDiv.children("a").last(); for (var i=0;i < topicArray.length; i++) { $('
'+topicArray[i]+'
').insertBefore(last) diff --git a/public/vendor/librejs.html b/public/vendor/librejs.html index 68586064c6457..5381850b8553b 100644 --- a/public/vendor/librejs.html +++ b/public/vendor/librejs.html @@ -110,6 +110,11 @@ Apache-2.0-only pdf.js-v1.4.20.tar.gz + + u2f-ref-code + BSD 3-Clause + u2f-ref-code-master.zip + font-awesome - fonts OFL diff --git a/public/vendor/plugins/u2f/u2f-api.js b/public/vendor/plugins/u2f/u2f-api.js new file mode 100644 index 0000000000000..a0518ef04aeff --- /dev/null +++ b/public/vendor/plugins/u2f/u2f-api.js @@ -0,0 +1,748 @@ +//Copyright 2014-2015 Google Inc. All rights reserved. + +//Use of this source code is governed by a BSD-style +//license that can be found in the LICENSE file or at +//https://developers.google.com/open-source/licenses/bsd + +/** + * @fileoverview The U2F api. + */ +'use strict'; + + +/** + * Namespace for the U2F api. + * @type {Object} + */ +var u2f = u2f || {}; + +/** + * FIDO U2F Javascript API Version + * @number + */ +var js_api_version; + +/** + * The U2F extension id + * @const {string} + */ +// The Chrome packaged app extension ID. +// Uncomment this if you want to deploy a server instance that uses +// the package Chrome app and does not require installing the U2F Chrome extension. +u2f.EXTENSION_ID = 'kmendfapggjehodndflmmgagdbamhnfd'; +// The U2F Chrome extension ID. +// Uncomment this if you want to deploy a server instance that uses +// the U2F Chrome extension to authenticate. +// u2f.EXTENSION_ID = 'pfboblefjcgdjicmnffhdgionmgcdmne'; + + +/** + * Message types for messsages to/from the extension + * @const + * @enum {string} + */ +u2f.MessageTypes = { + 'U2F_REGISTER_REQUEST': 'u2f_register_request', + 'U2F_REGISTER_RESPONSE': 'u2f_register_response', + 'U2F_SIGN_REQUEST': 'u2f_sign_request', + 'U2F_SIGN_RESPONSE': 'u2f_sign_response', + 'U2F_GET_API_VERSION_REQUEST': 'u2f_get_api_version_request', + 'U2F_GET_API_VERSION_RESPONSE': 'u2f_get_api_version_response' +}; + + +/** + * Response status codes + * @const + * @enum {number} + */ +u2f.ErrorCodes = { + 'OK': 0, + 'OTHER_ERROR': 1, + 'BAD_REQUEST': 2, + 'CONFIGURATION_UNSUPPORTED': 3, + 'DEVICE_INELIGIBLE': 4, + 'TIMEOUT': 5 +}; + + +/** + * A message for registration requests + * @typedef {{ + * type: u2f.MessageTypes, + * appId: ?string, + * timeoutSeconds: ?number, + * requestId: ?number + * }} + */ +u2f.U2fRequest; + + +/** + * A message for registration responses + * @typedef {{ + * type: u2f.MessageTypes, + * responseData: (u2f.Error | u2f.RegisterResponse | u2f.SignResponse), + * requestId: ?number + * }} + */ +u2f.U2fResponse; + + +/** + * An error object for responses + * @typedef {{ + * errorCode: u2f.ErrorCodes, + * errorMessage: ?string + * }} + */ +u2f.Error; + +/** + * Data object for a single sign request. + * @typedef {enum {BLUETOOTH_RADIO, BLUETOOTH_LOW_ENERGY, USB, NFC}} + */ +u2f.Transport; + + +/** + * Data object for a single sign request. + * @typedef {Array} + */ +u2f.Transports; + +/** + * Data object for a single sign request. + * @typedef {{ + * version: string, + * challenge: string, + * keyHandle: string, + * appId: string + * }} + */ +u2f.SignRequest; + + +/** + * Data object for a sign response. + * @typedef {{ + * keyHandle: string, + * signatureData: string, + * clientData: string + * }} + */ +u2f.SignResponse; + + +/** + * Data object for a registration request. + * @typedef {{ + * version: string, + * challenge: string + * }} + */ +u2f.RegisterRequest; + + +/** + * Data object for a registration response. + * @typedef {{ + * version: string, + * keyHandle: string, + * transports: Transports, + * appId: string + * }} + */ +u2f.RegisterResponse; + + +/** + * Data object for a registered key. + * @typedef {{ + * version: string, + * keyHandle: string, + * transports: ?Transports, + * appId: ?string + * }} + */ +u2f.RegisteredKey; + + +/** + * Data object for a get API register response. + * @typedef {{ + * js_api_version: number + * }} + */ +u2f.GetJsApiVersionResponse; + + +//Low level MessagePort API support + +/** + * Sets up a MessagePort to the U2F extension using the + * available mechanisms. + * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback + */ +u2f.getMessagePort = function(callback) { + if (typeof chrome != 'undefined' && chrome.runtime) { + // The actual message here does not matter, but we need to get a reply + // for the callback to run. Thus, send an empty signature request + // in order to get a failure response. + var msg = { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + signRequests: [] + }; + chrome.runtime.sendMessage(u2f.EXTENSION_ID, msg, function() { + if (!chrome.runtime.lastError) { + // We are on a whitelisted origin and can talk directly + // with the extension. + u2f.getChromeRuntimePort_(callback); + } else { + // chrome.runtime was available, but we couldn't message + // the extension directly, use iframe + u2f.getIframePort_(callback); + } + }); + } else if (u2f.isAndroidChrome_()) { + u2f.getAuthenticatorPort_(callback); + } else if (u2f.isIosChrome_()) { + u2f.getIosPort_(callback); + } else { + // chrome.runtime was not available at all, which is normal + // when this origin doesn't have access to any extensions. + u2f.getIframePort_(callback); + } +}; + +/** + * Detect chrome running on android based on the browser's useragent. + * @private + */ +u2f.isAndroidChrome_ = function() { + var userAgent = navigator.userAgent; + return userAgent.indexOf('Chrome') != -1 && + userAgent.indexOf('Android') != -1; +}; + +/** + * Detect chrome running on iOS based on the browser's platform. + * @private + */ +u2f.isIosChrome_ = function() { + return ["iPhone", "iPad", "iPod"].indexOf(navigator.platform) > -1; +}; + +/** + * Connects directly to the extension via chrome.runtime.connect. + * @param {function(u2f.WrappedChromeRuntimePort_)} callback + * @private + */ +u2f.getChromeRuntimePort_ = function(callback) { + var port = chrome.runtime.connect(u2f.EXTENSION_ID, + {'includeTlsChannelId': true}); + setTimeout(function() { + callback(new u2f.WrappedChromeRuntimePort_(port)); + }, 0); +}; + +/** + * Return a 'port' abstraction to the Authenticator app. + * @param {function(u2f.WrappedAuthenticatorPort_)} callback + * @private + */ +u2f.getAuthenticatorPort_ = function(callback) { + setTimeout(function() { + callback(new u2f.WrappedAuthenticatorPort_()); + }, 0); +}; + +/** + * Return a 'port' abstraction to the iOS client app. + * @param {function(u2f.WrappedIosPort_)} callback + * @private + */ +u2f.getIosPort_ = function(callback) { + setTimeout(function() { + callback(new u2f.WrappedIosPort_()); + }, 0); +}; + +/** + * A wrapper for chrome.runtime.Port that is compatible with MessagePort. + * @param {Port} port + * @constructor + * @private + */ +u2f.WrappedChromeRuntimePort_ = function(port) { + this.port_ = port; +}; + +/** + * Format and return a sign request compliant with the JS API version supported by the extension. + * @param {Array} signRequests + * @param {number} timeoutSeconds + * @param {number} reqId + * @return {Object} + */ +u2f.formatSignRequest_ = + function(appId, challenge, registeredKeys, timeoutSeconds, reqId) { + if (js_api_version === undefined || js_api_version < 1.1) { + // Adapt request to the 1.0 JS API + var signRequests = []; + for (var i = 0; i < registeredKeys.length; i++) { + signRequests[i] = { + version: registeredKeys[i].version, + challenge: challenge, + keyHandle: registeredKeys[i].keyHandle, + appId: appId + }; + } + return { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + signRequests: signRequests, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; + } + // JS 1.1 API + return { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + appId: appId, + challenge: challenge, + registeredKeys: registeredKeys, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; + }; + +/** + * Format and return a register request compliant with the JS API version supported by the extension.. + * @param {Array} signRequests + * @param {Array} signRequests + * @param {number} timeoutSeconds + * @param {number} reqId + * @return {Object} + */ +u2f.formatRegisterRequest_ = + function(appId, registeredKeys, registerRequests, timeoutSeconds, reqId) { + if (js_api_version === undefined || js_api_version < 1.1) { + // Adapt request to the 1.0 JS API + for (var i = 0; i < registerRequests.length; i++) { + registerRequests[i].appId = appId; + } + var signRequests = []; + for (var i = 0; i < registeredKeys.length; i++) { + signRequests[i] = { + version: registeredKeys[i].version, + challenge: registerRequests[0], + keyHandle: registeredKeys[i].keyHandle, + appId: appId + }; + } + return { + type: u2f.MessageTypes.U2F_REGISTER_REQUEST, + signRequests: signRequests, + registerRequests: registerRequests, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; + } + // JS 1.1 API + return { + type: u2f.MessageTypes.U2F_REGISTER_REQUEST, + appId: appId, + registerRequests: registerRequests, + registeredKeys: registeredKeys, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; + }; + + +/** + * Posts a message on the underlying channel. + * @param {Object} message + */ +u2f.WrappedChromeRuntimePort_.prototype.postMessage = function(message) { + this.port_.postMessage(message); +}; + + +/** + * Emulates the HTML 5 addEventListener interface. Works only for the + * onmessage event, which is hooked up to the chrome.runtime.Port.onMessage. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedChromeRuntimePort_.prototype.addEventListener = + function(eventName, handler) { + var name = eventName.toLowerCase(); + if (name == 'message' || name == 'onmessage') { + this.port_.onMessage.addListener(function(message) { + // Emulate a minimal MessageEvent object + handler({'data': message}); + }); + } else { + console.error('WrappedChromeRuntimePort only supports onMessage'); + } + }; + +/** + * Wrap the Authenticator app with a MessagePort interface. + * @constructor + * @private + */ +u2f.WrappedAuthenticatorPort_ = function() { + this.requestId_ = -1; + this.requestObject_ = null; +} + +/** + * Launch the Authenticator intent. + * @param {Object} message + */ +u2f.WrappedAuthenticatorPort_.prototype.postMessage = function(message) { + var intentUrl = + u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ + + ';S.request=' + encodeURIComponent(JSON.stringify(message)) + + ';end'; + document.location = intentUrl; +}; + +/** + * Tells what type of port this is. + * @return {String} port type + */ +u2f.WrappedAuthenticatorPort_.prototype.getPortType = function() { + return "WrappedAuthenticatorPort_"; +}; + + +/** + * Emulates the HTML 5 addEventListener interface. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedAuthenticatorPort_.prototype.addEventListener = function(eventName, handler) { + var name = eventName.toLowerCase(); + if (name == 'message') { + var self = this; + /* Register a callback to that executes when + * chrome injects the response. */ + window.addEventListener( + 'message', self.onRequestUpdate_.bind(self, handler), false); + } else { + console.error('WrappedAuthenticatorPort only supports message'); + } +}; + +/** + * Callback invoked when a response is received from the Authenticator. + * @param function({data: Object}) callback + * @param {Object} message message Object + */ +u2f.WrappedAuthenticatorPort_.prototype.onRequestUpdate_ = + function(callback, message) { + var messageObject = JSON.parse(message.data); + var intentUrl = messageObject['intentURL']; + + var errorCode = messageObject['errorCode']; + var responseObject = null; + if (messageObject.hasOwnProperty('data')) { + responseObject = /** @type {Object} */ ( + JSON.parse(messageObject['data'])); + } + + callback({'data': responseObject}); + }; + +/** + * Base URL for intents to Authenticator. + * @const + * @private + */ +u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ = + 'intent:#Intent;action=com.google.android.apps.authenticator.AUTHENTICATE'; + +/** + * Wrap the iOS client app with a MessagePort interface. + * @constructor + * @private + */ +u2f.WrappedIosPort_ = function() {}; + +/** + * Launch the iOS client app request + * @param {Object} message + */ +u2f.WrappedIosPort_.prototype.postMessage = function(message) { + var str = JSON.stringify(message); + var url = "u2f://auth?" + encodeURI(str); + location.replace(url); +}; + +/** + * Tells what type of port this is. + * @return {String} port type + */ +u2f.WrappedIosPort_.prototype.getPortType = function() { + return "WrappedIosPort_"; +}; + +/** + * Emulates the HTML 5 addEventListener interface. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedIosPort_.prototype.addEventListener = function(eventName, handler) { + var name = eventName.toLowerCase(); + if (name !== 'message') { + console.error('WrappedIosPort only supports message'); + } +}; + +/** + * Sets up an embedded trampoline iframe, sourced from the extension. + * @param {function(MessagePort)} callback + * @private + */ +u2f.getIframePort_ = function(callback) { + // Create the iframe + var iframeOrigin = 'chrome-extension://' + u2f.EXTENSION_ID; + var iframe = document.createElement('iframe'); + iframe.src = iframeOrigin + '/u2f-comms.html'; + iframe.setAttribute('style', 'display:none'); + document.body.appendChild(iframe); + + var channel = new MessageChannel(); + var ready = function(message) { + if (message.data == 'ready') { + channel.port1.removeEventListener('message', ready); + callback(channel.port1); + } else { + console.error('First event on iframe port was not "ready"'); + } + }; + channel.port1.addEventListener('message', ready); + channel.port1.start(); + + iframe.addEventListener('load', function() { + // Deliver the port to the iframe and initialize + iframe.contentWindow.postMessage('init', iframeOrigin, [channel.port2]); + }); +}; + + +//High-level JS API + +/** + * Default extension response timeout in seconds. + * @const + */ +u2f.EXTENSION_TIMEOUT_SEC = 30; + +/** + * A singleton instance for a MessagePort to the extension. + * @type {MessagePort|u2f.WrappedChromeRuntimePort_} + * @private + */ +u2f.port_ = null; + +/** + * Callbacks waiting for a port + * @type {Array} + * @private + */ +u2f.waitingForPort_ = []; + +/** + * A counter for requestIds. + * @type {number} + * @private + */ +u2f.reqCounter_ = 0; + +/** + * A map from requestIds to client callbacks + * @type {Object.} + * @private + */ +u2f.callbackMap_ = {}; + +/** + * Creates or retrieves the MessagePort singleton to use. + * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback + * @private + */ +u2f.getPortSingleton_ = function(callback) { + if (u2f.port_) { + callback(u2f.port_); + } else { + if (u2f.waitingForPort_.length == 0) { + u2f.getMessagePort(function(port) { + u2f.port_ = port; + u2f.port_.addEventListener('message', + /** @type {function(Event)} */ (u2f.responseHandler_)); + + // Careful, here be async callbacks. Maybe. + while (u2f.waitingForPort_.length) + u2f.waitingForPort_.shift()(u2f.port_); + }); + } + u2f.waitingForPort_.push(callback); + } +}; + +/** + * Handles response messages from the extension. + * @param {MessageEvent.} message + * @private + */ +u2f.responseHandler_ = function(message) { + var response = message.data; + var reqId = response['requestId']; + if (!reqId || !u2f.callbackMap_[reqId]) { + console.error('Unknown or missing requestId in response.'); + return; + } + var cb = u2f.callbackMap_[reqId]; + delete u2f.callbackMap_[reqId]; + cb(response['responseData']); +}; + +/** + * Dispatches an array of sign requests to available U2F tokens. + * If the JS API version supported by the extension is unknown, it first sends a + * message to the extension to find out the supported API version and then it sends + * the sign request. + * @param {string=} appId + * @param {string=} challenge + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.SignResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sign = function(appId, challenge, registeredKeys, callback, opt_timeoutSeconds) { + if (js_api_version === undefined) { + // Send a message to get the extension to JS API version, then send the actual sign request. + u2f.getApiVersion( + function (response) { + js_api_version = response['js_api_version'] === undefined ? 0 : response['js_api_version']; + console.log("Extension JS API Version: ", js_api_version); + u2f.sendSignRequest(appId, challenge, registeredKeys, callback, opt_timeoutSeconds); + }); + } else { + // We know the JS API version. Send the actual sign request in the supported API version. + u2f.sendSignRequest(appId, challenge, registeredKeys, callback, opt_timeoutSeconds); + } +}; + +/** + * Dispatches an array of sign requests to available U2F tokens. + * @param {string=} appId + * @param {string=} challenge + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.SignResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sendSignRequest = function(appId, challenge, registeredKeys, callback, opt_timeoutSeconds) { + u2f.getPortSingleton_(function(port) { + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ? + opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC); + var req = u2f.formatSignRequest_(appId, challenge, registeredKeys, timeoutSeconds, reqId); + port.postMessage(req); + }); +}; + +/** + * Dispatches register requests to available U2F tokens. An array of sign + * requests identifies already registered tokens. + * If the JS API version supported by the extension is unknown, it first sends a + * message to the extension to find out the supported API version and then it sends + * the register request. + * @param {string=} appId + * @param {Array} registerRequests + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.RegisterResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.register = function(appId, registerRequests, registeredKeys, callback, opt_timeoutSeconds) { + if (js_api_version === undefined) { + // Send a message to get the extension to JS API version, then send the actual register request. + u2f.getApiVersion( + function (response) { + js_api_version = response['js_api_version'] === undefined ? 0: response['js_api_version']; + console.log("Extension JS API Version: ", js_api_version); + u2f.sendRegisterRequest(appId, registerRequests, registeredKeys, + callback, opt_timeoutSeconds); + }); + } else { + // We know the JS API version. Send the actual register request in the supported API version. + u2f.sendRegisterRequest(appId, registerRequests, registeredKeys, + callback, opt_timeoutSeconds); + } +}; + +/** + * Dispatches register requests to available U2F tokens. An array of sign + * requests identifies already registered tokens. + * @param {string=} appId + * @param {Array} registerRequests + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.RegisterResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sendRegisterRequest = function(appId, registerRequests, registeredKeys, callback, opt_timeoutSeconds) { + u2f.getPortSingleton_(function(port) { + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ? + opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC); + var req = u2f.formatRegisterRequest_( + appId, registeredKeys, registerRequests, timeoutSeconds, reqId); + port.postMessage(req); + }); +}; + + +/** + * Dispatches a message to the extension to find out the supported + * JS API version. + * If the user is on a mobile phone and is thus using Google Authenticator instead + * of the Chrome extension, don't send the request and simply return 0. + * @param {function((u2f.Error|u2f.GetJsApiVersionResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.getApiVersion = function(callback, opt_timeoutSeconds) { + u2f.getPortSingleton_(function(port) { + // If we are using Android Google Authenticator or iOS client app, + // do not fire an intent to ask which JS API version to use. + if (port.getPortType) { + var apiVersion; + switch (port.getPortType()) { + case 'WrappedIosPort_': + case 'WrappedAuthenticatorPort_': + apiVersion = 1.1; + break; + + default: + apiVersion = 0; + break; + } + callback({ 'js_api_version': apiVersion }); + return; + } + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var req = { + type: u2f.MessageTypes.U2F_GET_API_VERSION_REQUEST, + timeoutSeconds: (typeof opt_timeoutSeconds !== 'undefined' ? + opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC), + requestId: reqId + }; + port.postMessage(req); + }); +}; diff --git a/routers/routes/routes.go b/routers/routes/routes.go index 40b5f4bfb37c2..927c6ff2795fa 100644 --- a/routers/routes/routes.go +++ b/routers/routes/routes.go @@ -5,6 +5,7 @@ package routes import ( + "encoding/gob" "os" "path" "time" @@ -27,6 +28,7 @@ import ( "code.gitea.io/gitea/routers/private" "code.gitea.io/gitea/routers/repo" "code.gitea.io/gitea/routers/user" + "github.com/tstranex/u2f" "github.com/go-macaron/binding" "github.com/go-macaron/cache" @@ -42,6 +44,7 @@ import ( // NewMacaron initializes Macaron instance. func NewMacaron() *macaron.Macaron { + gob.Register(&u2f.Challenge{}) m := macaron.New() if !setting.DisableRouterLog { m.Use(macaron.Logger()) @@ -213,6 +216,12 @@ func RegisterRoutes(m *macaron.Macaron) { m.Get("/scratch", user.TwoFactorScratch) m.Post("/scratch", bindIgnErr(auth.TwoFactorScratchAuthForm{}), user.TwoFactorScratchPost) }) + m.Group("/u2f", func() { + m.Get("", user.U2F) + m.Get("/challenge", user.U2FChallenge) + m.Post("/sign", bindIgnErr(u2f.SignResponse{}), user.U2FSign) + + }) }, reqSignOut) m.Group("/user/settings", func() { @@ -234,6 +243,11 @@ func RegisterRoutes(m *macaron.Macaron) { m.Get("/enroll", user.SettingsTwoFactorEnroll) m.Post("/enroll", bindIgnErr(auth.TwoFactorAuthForm{}), user.SettingsTwoFactorEnrollPost) }) + m.Group("/u2f", func() { + m.Post("/request_register", bindIgnErr(auth.U2FRegistrationForm{}), user.U2FRegister) + m.Post("/register", bindIgnErr(u2f.RegisterResponse{}), user.U2FRegisterPost) + m.Post("/delete", bindIgnErr(auth.U2FDeleteForm{}), user.U2FDelete) + }) m.Group("/openid", func() { m.Post("", bindIgnErr(auth.AddOpenIDForm{}), user.SettingsOpenIDPost) m.Post("/delete", user.DeleteOpenID) diff --git a/routers/user/auth.go b/routers/user/auth.go index c8e1ada0db161..6502963f54e7e 100644 --- a/routers/user/auth.go +++ b/routers/user/auth.go @@ -18,6 +18,7 @@ import ( "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" + "github.com/tstranex/u2f" "github.com/go-macaron/captcha" "github.com/markbates/goth" @@ -35,6 +36,7 @@ const ( tplTwofa base.TplName = "user/auth/twofa" tplTwofaScratch base.TplName = "user/auth/twofa_scratch" tplLinkAccount base.TplName = "user/auth/link_account" + tplU2F base.TplName = "user/auth/u2f" ) // AutoSignIn reads cookie and try to auto-login. @@ -159,7 +161,6 @@ func SignInPost(ctx *context.Context, form auth.SignInForm) { } return } - // If this user is enrolled in 2FA, we can't sign the user in just yet. // Instead, redirect them to the 2FA authentication page. _, err = models.GetTwoFactorByUID(u.ID) @@ -175,6 +176,13 @@ func SignInPost(ctx *context.Context, form auth.SignInForm) { // User needs to use 2FA, save data and redirect to 2FA page. ctx.Session.Set("twofaUid", u.ID) ctx.Session.Set("twofaRemember", form.Remember) + + regs, err := models.GetU2FRegistrationsByUID(u.ID) + if err == nil && len(regs) > 0 { + ctx.Redirect(setting.AppSubURL + "/user/u2f") + return + } + ctx.Redirect(setting.AppSubURL + "/user/two_factor") } @@ -317,12 +325,101 @@ func TwoFactorScratchPost(ctx *context.Context, form auth.TwoFactorScratchAuthFo ctx.RenderWithErr(ctx.Tr("auth.twofa_scratch_token_incorrect"), tplTwofaScratch, auth.TwoFactorScratchAuthForm{}) } +// U2F shows the U2F login page +func U2F(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("twofa") + ctx.Data["RequireU2F"] = true + // Check auto-login. + if checkAutoLogin(ctx) { + return + } + + // Ensure user is in a 2FA session. + if ctx.Session.Get("twofaUid") == nil { + ctx.ServerError("UserSignIn", errors.New("not in U2F session")) + return + } + + ctx.HTML(200, tplU2F) +} + +// U2FChallenge submits a sign challenge to the browser +func U2FChallenge(ctx *context.Context) { + // Ensure user is in a U2F session. + idSess := ctx.Session.Get("twofaUid") + if idSess == nil { + ctx.ServerError("UserSignIn", errors.New("not in U2F session")) + return + } + id := idSess.(int64) + regs, err := models.GetU2FRegistrationsByUID(id) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + if len(regs) == 0 { + ctx.ServerError("UserSignIn", errors.New("no device registered")) + return + } + challenge, err := u2f.NewChallenge(setting.U2F.AppID, setting.U2F.TrustedFacets) + if err = ctx.Session.Set("u2fChallenge", challenge); err != nil { + ctx.ServerError("UserSignIn", err) + return + } + ctx.JSON(200, challenge.SignRequest(regs.ToRegistrations())) +} + +// U2FSign authenticates the user by signResp +func U2FSign(ctx *context.Context, signResp u2f.SignResponse) { + challSess := ctx.Session.Get("u2fChallenge") + idSess := ctx.Session.Get("twofaUid") + if challSess == nil || idSess == nil { + ctx.ServerError("UserSignIn", errors.New("not in U2F session")) + return + } + challenge := challSess.(*u2f.Challenge) + id := idSess.(int64) + regs, err := models.GetU2FRegistrationsByUID(id) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + for _, reg := range regs { + r, err := reg.Parse() + if err != nil { + log.Fatal(4, "parsing u2f registration: %v", err) + continue + } + newCounter, authErr := r.Authenticate(signResp, *challenge, reg.Counter) + if authErr == nil { + reg.Counter = newCounter + user, err := models.GetUserByID(id) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + remember := ctx.Session.Get("twofaRemember").(bool) + if err := reg.UpdateCounter(); err != nil { + ctx.ServerError("UserSignIn", err) + return + } + redirect := handleSignInFull(ctx, user, remember, false) + if redirect == "" { + redirect = setting.AppSubURL + "/" + } + ctx.PlainText(200, []byte(redirect)) + return + } + } + ctx.Error(401) +} + // This handles the final part of the sign-in process of the user. func handleSignIn(ctx *context.Context, u *models.User, remember bool) { handleSignInFull(ctx, u, remember, true) } -func handleSignInFull(ctx *context.Context, u *models.User, remember bool, obeyRedirect bool) { +func handleSignInFull(ctx *context.Context, u *models.User, remember bool, obeyRedirect bool) string { if remember { days := 86400 * setting.LogInRememberDays ctx.SetCookie(setting.CookieUserName, u.Name, days, setting.AppSubURL) @@ -336,6 +433,7 @@ func handleSignInFull(ctx *context.Context, u *models.User, remember bool, obeyR ctx.Session.Delete("openid_determined_username") ctx.Session.Delete("twofaUid") ctx.Session.Delete("twofaRemember") + ctx.Session.Delete("u2fChallenge") ctx.Session.Set("uid", u.ID) ctx.Session.Set("uname", u.Name) @@ -345,7 +443,7 @@ func handleSignInFull(ctx *context.Context, u *models.User, remember bool, obeyR u.Language = ctx.Locale.Language() if err := models.UpdateUserCols(u, "language"); err != nil { log.Error(4, fmt.Sprintf("Error updating user language [user: %d, locale: %s]", u.ID, u.Language)) - return + return setting.AppSubURL + "/" } } @@ -358,7 +456,7 @@ func handleSignInFull(ctx *context.Context, u *models.User, remember bool, obeyR u.SetLastLogin() if err := models.UpdateUserCols(u, "last_login_unix"); err != nil { ctx.ServerError("UpdateUserCols", err) - return + return setting.AppSubURL + "/" } if redirectTo, _ := url.QueryUnescape(ctx.GetCookie("redirect_to")); len(redirectTo) > 0 { @@ -366,12 +464,13 @@ func handleSignInFull(ctx *context.Context, u *models.User, remember bool, obeyR if obeyRedirect { ctx.RedirectToFirst(redirectTo) } - return + return redirectTo } if obeyRedirect { ctx.Redirect(setting.AppSubURL + "/") } + return setting.AppSubURL + "/" } // SignInOAuth handles the OAuth2 login buttons diff --git a/routers/user/setting.go b/routers/user/setting.go index 1c760e210c8a0..0bfd6e5b164a0 100644 --- a/routers/user/setting.go +++ b/routers/user/setting.go @@ -6,8 +6,11 @@ package user import ( "bytes" + "encoding/base64" "errors" "fmt" + "html/template" + "image/png" "io/ioutil" "strings" @@ -15,10 +18,7 @@ import ( "github.com/Unknwon/i18n" "github.com/pquerna/otp" "github.com/pquerna/otp/totp" - - "encoding/base64" - "html/template" - "image/png" + "github.com/tstranex/u2f" "code.gitea.io/gitea/models" "code.gitea.io/gitea/modules/auth" @@ -354,6 +354,14 @@ func SettingsSecurity(ctx *context.Context) { } } ctx.Data["TwofaEnrolled"] = enrolled + if enrolled { + ctx.Data["U2FRegistrations"], err = models.GetU2FRegistrationsByUID(ctx.User.ID) + if err != nil { + ctx.ServerError("GetU2FRegistrationsByUID", err) + return + } + ctx.Data["RequireU2F"] = true + } accountLinks, err := models.ListAccountLinks(ctx.User) if err != nil { @@ -760,6 +768,89 @@ func SettingsTwoFactorEnrollPost(ctx *context.Context, form auth.TwoFactorAuthFo ctx.Redirect(setting.AppSubURL + "/user/settings/security") } +// U2FRegister initializes the u2f registration procedure +func U2FRegister(ctx *context.Context, form auth.U2FRegistrationForm) { + if form.Name == "" { + ctx.Error(409) + return + } + challenge, err := u2f.NewChallenge(setting.U2F.AppID, setting.U2F.TrustedFacets) + if err != nil { + ctx.ServerError("NewChallenge", err) + return + } + err = ctx.Session.Set("u2fChallenge", challenge) + if err != nil { + ctx.ServerError("Session.Set", err) + return + } + regs, err := models.GetU2FRegistrationsByUID(ctx.User.ID) + if err != nil { + ctx.ServerError("GetU2FRegistrationsByUID", err) + return + } + for _, reg := range regs { + if reg.Name == form.Name { + ctx.Error(409, "Name already taken") + return + } + } + ctx.Session.Set("u2fName", form.Name) + ctx.JSON(200, u2f.NewWebRegisterRequest(challenge, regs.ToRegistrations())) +} + +// U2FRegisterPost receives the response of the security key +func U2FRegisterPost(ctx *context.Context, response u2f.RegisterResponse) { + challSess := ctx.Session.Get("u2fChallenge") + u2fName := ctx.Session.Get("u2fName") + if challSess == nil || u2fName == nil { + ctx.ServerError("U2FRegisterPost", errors.New("not in U2F session")) + return + } + challenge := challSess.(*u2f.Challenge) + name := u2fName.(string) + config := &u2f.Config{ + // Chrome 66+ doesn't return the device's attestation + // certificate by default. + SkipAttestationVerify: true, + } + reg, err := u2f.Register(response, *challenge, config) + if err != nil { + ctx.ServerError("u2f.Register", err) + return + } + if _, err = models.CreateRegistration(ctx.User, name, reg); err != nil { + ctx.ServerError("u2f.Register", err) + return + } + ctx.Status(200) +} + +// U2FDelete deletes an security key by id +func U2FDelete(ctx *context.Context, form auth.U2FDeleteForm) { + reg, err := models.GetU2FRegistrationByID(form.ID) + if err != nil { + if models.IsErrU2FRegistrationNotExist(err) { + ctx.Status(200) + return + } + ctx.ServerError("GetU2FRegistrationByID", err) + return + } + if reg.UserID != ctx.User.ID { + ctx.Status(401) + return + } + if err := models.DeleteRegistration(reg); err != nil { + ctx.ServerError("DeleteRegistration", err) + return + } + ctx.JSON(200, map[string]interface{}{ + "redirect": setting.AppSubURL + "/user/settings/security", + }) + return +} + // SettingsOrganization render all the organization of the user func SettingsOrganization(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("settings") diff --git a/templates/base/footer.tmpl b/templates/base/footer.tmpl index c0f5a83d7b5fc..96c1908565ae3 100644 --- a/templates/base/footer.tmpl +++ b/templates/base/footer.tmpl @@ -64,6 +64,9 @@ {{if .RequireDropzone}} {{end}} +{{if .RequireU2F}} + +{{end}} {{if .RequireTribute}} diff --git a/templates/user/auth/u2f.tmpl b/templates/user/auth/u2f.tmpl new file mode 100644 index 0000000000000..8de79481ec295 --- /dev/null +++ b/templates/user/auth/u2f.tmpl @@ -0,0 +1,21 @@ +{{template "base/head" .}} + +{{template "base/footer" .}} diff --git a/templates/user/settings/security.tmpl b/templates/user/settings/security.tmpl index 8e7044f7dfe04..c2c99c79794d5 100644 --- a/templates/user/settings/security.tmpl +++ b/templates/user/settings/security.tmpl @@ -4,6 +4,7 @@
{{template "base/alert" .}} {{template "user/settings/security_twofa" .}} + {{template "user/settings/security_u2f" .}} {{template "user/settings/security_accountlinks" .}} {{if .EnableOpenIDSignIn}} {{template "user/settings/security_openid" .}} diff --git a/templates/user/settings/security_openid.tmpl b/templates/user/settings/security_openid.tmpl index 12f4aab4194c0..240fe2e53f5f1 100644 --- a/templates/user/settings/security_openid.tmpl +++ b/templates/user/settings/security_openid.tmpl @@ -43,7 +43,7 @@ {{.CsrfTokenHtml}}
- +
+
+
+ {{.Name}} +
+ + {{end}} + +
+ {{.CsrfTokenHtml}} +
+ + +
+ +
+ {{else}} + {{.i18n.Tr "settings.u2f_require_twofa"}} + {{end}} + + + + + + diff --git a/vendor/vendor.json b/vendor/vendor.json index 8b8972b16f114..be1f981705bac 100644 --- a/vendor/vendor.json +++ b/vendor/vendor.json @@ -1284,6 +1284,12 @@ "revision": "917f41c560270110ceb73c5b38be2a9127387071", "revisionTime": "2016-03-11T05:04:36Z" }, + { + "checksumSHA1": "NE1kNfAZ0AAXCUbwx196os/DSUE=", + "path": "github.com/tstranex/u2f", + "revision": "d21a03e0b1d9fc1df59ff54e7a513655c1748b0c", + "revisionTime": "2018-05-05T18:51:14Z" + }, { "checksumSHA1": "76U6GiRZsKjr9OWohbsdfKPok/E=", "path": "github.com/twinj/uuid", From a72944b0145770a1afa0b1f454d389ca4c0dacaa Mon Sep 17 00:00:00 2001 From: Jonas Franz Date: Tue, 15 May 2018 22:57:15 +0200 Subject: [PATCH 02/11] Add vendor library Add missing translations Signed-off-by: Jonas Franz --- options/locale/locale_en-US.ini | 2 + vendor/github.com/tstranex/u2f/LICENSE | 21 ++ vendor/github.com/tstranex/u2f/README.md | 97 +++++++++ vendor/github.com/tstranex/u2f/auth.go | 136 ++++++++++++ vendor/github.com/tstranex/u2f/certs.go | 89 ++++++++ vendor/github.com/tstranex/u2f/messages.go | 87 ++++++++ vendor/github.com/tstranex/u2f/register.go | 230 +++++++++++++++++++++ vendor/github.com/tstranex/u2f/util.go | 125 +++++++++++ 8 files changed, 787 insertions(+) create mode 100644 vendor/github.com/tstranex/u2f/LICENSE create mode 100644 vendor/github.com/tstranex/u2f/README.md create mode 100644 vendor/github.com/tstranex/u2f/auth.go create mode 100644 vendor/github.com/tstranex/u2f/certs.go create mode 100644 vendor/github.com/tstranex/u2f/messages.go create mode 100644 vendor/github.com/tstranex/u2f/register.go create mode 100644 vendor/github.com/tstranex/u2f/util.go diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 75bccaab57f5c..4a216745aeac4 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -460,6 +460,8 @@ u2f_require_twofa = Two-Factor-Authentication must be enrolled in order to use s u2f_register_key = Add Security Key u2f_nickname = Nickname u2f_press_button = Press the button on your security key to register it. +u2f_delete_key = Remove Security Key +u2f_delete_key_desc= If you remove a security key you cannot login with it anymore. Are you sure? manage_account_links = Manage Linked Accounts manage_account_links_desc = These external accounts are linked to your Gitea account. diff --git a/vendor/github.com/tstranex/u2f/LICENSE b/vendor/github.com/tstranex/u2f/LICENSE new file mode 100644 index 0000000000000..3c7279c6fc954 --- /dev/null +++ b/vendor/github.com/tstranex/u2f/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 The Go FIDO U2F Library Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/vendor/github.com/tstranex/u2f/README.md b/vendor/github.com/tstranex/u2f/README.md new file mode 100644 index 0000000000000..95de78f8b5fc4 --- /dev/null +++ b/vendor/github.com/tstranex/u2f/README.md @@ -0,0 +1,97 @@ +# Go FIDO U2F Library + +This Go package implements the parts of the FIDO U2F specification required on +the server side of an application. + +[![Build Status](https://travis-ci.org/tstranex/u2f.svg?branch=master)](https://travis-ci.org/tstranex/u2f) + +## Features + +- Native Go implementation +- No dependancies other than the Go standard library +- Token attestation certificate verification + +## Usage + +Please visit http://godoc.org/github.com/tstranex/u2f for the full +documentation. + +### How to enrol a new token + +```go +app_id := "http://localhost" + +// Send registration request to the browser. +c, _ := NewChallenge(app_id, []string{app_id}) +req, _ := c.RegisterRequest() + +// Read response from the browser. +var resp RegisterResponse +reg, err := Register(resp, c, nil) +if err != nil { + // Registration failed. +} + +// Store registration in the database. +``` + +### How to perform an authentication + +```go +// Fetch registration and counter from the database. +var reg Registration +var counter uint32 + +// Send authentication request to the browser. +c, _ := NewChallenge(app_id, []string{app_id}) +req, _ := c.SignRequest(reg) + +// Read response from the browser. +var resp SignResponse +newCounter, err := reg.Authenticate(resp, c, counter) +if err != nil { + // Authentication failed. +} + +// Store updated counter in the database. +``` + +## Installation + +``` +$ go get github.com/tstranex/u2f +``` + +## Example + +See u2fdemo/main.go for an full example server. To run it: + +``` +$ go install github.com/tstranex/u2f/u2fdemo +$ ./bin/u2fdemo +``` + +Open https://localhost:3483 in Chrome. +Ignore the SSL warning (due to the self-signed certificate for localhost). +You can then test registering and authenticating using your token. + +## Changelog + +- 2016-12-18: The package has been updated to work with the new + U2F Javascript 1.1 API specification. This causes some breaking changes. + + `SignRequest` has been replaced by `WebSignRequest` which now includes + multiple registrations. This is useful when the user has multiple devices + registered since you can now authenticate against any of them with a single + request. + + `WebRegisterRequest` has been introduced, which should generally be used + instead of using `RegisterRequest` directly. It includes the list of existing + registrations with the new registration request. If the user's device already + matches one of the existing registrations, it will refuse to re-register. + + `Challenge.RegisterRequest` has been replaced by `NewWebRegisterRequest`. + +## License + +The Go FIDO U2F Library is licensed under the MIT License. diff --git a/vendor/github.com/tstranex/u2f/auth.go b/vendor/github.com/tstranex/u2f/auth.go new file mode 100644 index 0000000000000..05c25f573101d --- /dev/null +++ b/vendor/github.com/tstranex/u2f/auth.go @@ -0,0 +1,136 @@ +// Go FIDO U2F Library +// Copyright 2015 The Go FIDO U2F Library Authors. All rights reserved. +// Use of this source code is governed by the MIT +// license that can be found in the LICENSE file. + +package u2f + +import ( + "crypto/ecdsa" + "crypto/sha256" + "encoding/asn1" + "errors" + "math/big" + "time" +) + +// SignRequest creates a request to initiate an authentication. +func (c *Challenge) SignRequest(regs []Registration) *WebSignRequest { + var sr WebSignRequest + sr.AppID = c.AppID + sr.Challenge = encodeBase64(c.Challenge) + for _, r := range regs { + rk := getRegisteredKey(c.AppID, r) + sr.RegisteredKeys = append(sr.RegisteredKeys, rk) + } + return &sr +} + +// ErrCounterTooLow is raised when the counter value received from the device is +// lower than last stored counter value. This may indicate that the device has +// been cloned (or is malfunctioning). The application may choose to disable +// the particular device as precaution. +var ErrCounterTooLow = errors.New("u2f: counter too low") + +// Authenticate validates a SignResponse authentication response. +// An error is returned if any part of the response fails to validate. +// The counter should be the counter associated with appropriate device +// (i.e. resp.KeyHandle). +// The latest counter value is returned, which the caller should store. +func (reg *Registration) Authenticate(resp SignResponse, c Challenge, counter uint32) (newCounter uint32, err error) { + if time.Now().Sub(c.Timestamp) > timeout { + return 0, errors.New("u2f: challenge has expired") + } + if resp.KeyHandle != encodeBase64(reg.KeyHandle) { + return 0, errors.New("u2f: wrong key handle") + } + + sigData, err := decodeBase64(resp.SignatureData) + if err != nil { + return 0, err + } + + clientData, err := decodeBase64(resp.ClientData) + if err != nil { + return 0, err + } + + ar, err := parseSignResponse(sigData) + if err != nil { + return 0, err + } + + if ar.Counter < counter { + return 0, ErrCounterTooLow + } + + if err := verifyClientData(clientData, c); err != nil { + return 0, err + } + + if err := verifyAuthSignature(*ar, ®.PubKey, c.AppID, clientData); err != nil { + return 0, err + } + + if !ar.UserPresenceVerified { + return 0, errors.New("u2f: user was not present") + } + + return ar.Counter, nil +} + +type ecdsaSig struct { + R, S *big.Int +} + +type authResp struct { + UserPresenceVerified bool + Counter uint32 + sig ecdsaSig + raw []byte +} + +func parseSignResponse(sd []byte) (*authResp, error) { + if len(sd) < 5 { + return nil, errors.New("u2f: data is too short") + } + + var ar authResp + + userPresence := sd[0] + if userPresence|1 != 1 { + return nil, errors.New("u2f: invalid user presence byte") + } + ar.UserPresenceVerified = userPresence == 1 + + ar.Counter = uint32(sd[1])<<24 | uint32(sd[2])<<16 | uint32(sd[3])<<8 | uint32(sd[4]) + + ar.raw = sd[:5] + + rest, err := asn1.Unmarshal(sd[5:], &ar.sig) + if err != nil { + return nil, err + } + if len(rest) != 0 { + return nil, errors.New("u2f: trailing data") + } + + return &ar, nil +} + +func verifyAuthSignature(ar authResp, pubKey *ecdsa.PublicKey, appID string, clientData []byte) error { + appParam := sha256.Sum256([]byte(appID)) + challenge := sha256.Sum256(clientData) + + var buf []byte + buf = append(buf, appParam[:]...) + buf = append(buf, ar.raw...) + buf = append(buf, challenge[:]...) + hash := sha256.Sum256(buf) + + if !ecdsa.Verify(pubKey, hash[:], ar.sig.R, ar.sig.S) { + return errors.New("u2f: invalid signature") + } + + return nil +} diff --git a/vendor/github.com/tstranex/u2f/certs.go b/vendor/github.com/tstranex/u2f/certs.go new file mode 100644 index 0000000000000..14d745a0095b5 --- /dev/null +++ b/vendor/github.com/tstranex/u2f/certs.go @@ -0,0 +1,89 @@ +// Go FIDO U2F Library +// Copyright 2015 The Go FIDO U2F Library Authors. All rights reserved. +// Use of this source code is governed by the MIT +// license that can be found in the LICENSE file. + +package u2f + +import ( + "crypto/x509" + "log" +) + +const plugUpCert = `-----BEGIN CERTIFICATE----- +MIIBrjCCAVSgAwIBAgIJAMGSvUZlGSGVMAoGCCqGSM49BAMCMDIxMDAuBgNVBAMM +J1BsdWctdXAgRklETyBJbnRlcm5hbCBBdHRlc3RhdGlvbiBDQSAjMTAeFw0xNDA5 +MjMxNjM3NTFaFw0zNDA5MjMxNjM3NTFaMDIxMDAuBgNVBAMMJ1BsdWctdXAgRklE +TyBJbnRlcm5hbCBBdHRlc3RhdGlvbiBDQSAjMTBZMBMGByqGSM49AgEGCCqGSM49 +AwEHA0IABH9mscDgEHo4AUh7J8JHqRxsSVxbvsbe6Pxy5cUFKfQlWNjxRrZcbhOb +UY3WsAwmKuUdOcghbpTILhdp8LG9z5GjUzBRMA8GA1UdEwEB/wQFMAMBAf8wHQYD +VR0OBBYEFM+nRPKhYlDwOemShePaUOd9sDqoMB8GA1UdIwQYMBaAFM+nRPKhYlDw +OemShePaUOd9sDqoMAoGCCqGSM49BAMCA0gAMEUCIQDVzqnX1rgvyJaZ7WZUm1ED +hJKSsDxRXEnH+/voqpq/zgIgH4RUR6vr9YNrkzuCq5R07gF7P4qhtg/4jy+dhl7o +NAU= +-----END CERTIFICATE----- +` + +const neowaveCert = `-----BEGIN CERTIFICATE----- +MIICJDCCAcugAwIBAgIJAIo+0R9DGvSBMAoGCCqGSM49BAMCMG8xCzAJBgNVBAYT +AkZSMQ8wDQYDVQQIDAZGcmFuY2UxETAPBgNVBAcMCEdhcmRhbm5lMRAwDgYDVQQK +DAdOZW93YXZlMSowKAYDVQQDDCFOZW93YXZlIEtFWURPIEZJRE8gVTJGIENBIEJh +dGNoIDEwHhcNMTUwMTI4MTA1ODM1WhcNMjUwMTI1MTA1ODM1WjBvMQswCQYDVQQG +EwJGUjEPMA0GA1UECAwGRnJhbmNlMREwDwYDVQQHDAhHYXJkYW5uZTEQMA4GA1UE +CgwHTmVvd2F2ZTEqMCgGA1UEAwwhTmVvd2F2ZSBLRVlETyBGSURPIFUyRiBDQSBC +YXRjaCAxMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEBlUmE1BRE/M/CE/ZCN+x +eutfnVsThMwIDN+4DL9gqXoKCeRMiDQ1zwm/yQS80BYSEz7Du9RU+2mlnyhwhu+f +BqNQME4wHQYDVR0OBBYEFF42te8/iq5HGom4sIhgkJWLq5jkMB8GA1UdIwQYMBaA +FF42te8/iq5HGom4sIhgkJWLq5jkMAwGA1UdEwQFMAMBAf8wCgYIKoZIzj0EAwID +RwAwRAIgVTxBFb2Hclq5Yi5gQp6WoZAcHETfKASvTQVOE88REGQCIA5DcwGVLsZB +QTb94Xgtb/WUieCvmwukFl/gEO15f3uA +-----END CERTIFICATE----- +` + +const yubicoRootCert = `-----BEGIN CERTIFICATE----- +MIIDHjCCAgagAwIBAgIEG0BT9zANBgkqhkiG9w0BAQsFADAuMSwwKgYDVQQDEyNZ +dWJpY28gVTJGIFJvb3QgQ0EgU2VyaWFsIDQ1NzIwMDYzMTAgFw0xNDA4MDEwMDAw +MDBaGA8yMDUwMDkwNDAwMDAwMFowLjEsMCoGA1UEAxMjWXViaWNvIFUyRiBSb290 +IENBIFNlcmlhbCA0NTcyMDA2MzEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK +AoIBAQC/jwYuhBVlqaiYWEMsrWFisgJ+PtM91eSrpI4TK7U53mwCIawSDHy8vUmk +5N2KAj9abvT9NP5SMS1hQi3usxoYGonXQgfO6ZXyUA9a+KAkqdFnBnlyugSeCOep +8EdZFfsaRFtMjkwz5Gcz2Py4vIYvCdMHPtwaz0bVuzneueIEz6TnQjE63Rdt2zbw +nebwTG5ZybeWSwbzy+BJ34ZHcUhPAY89yJQXuE0IzMZFcEBbPNRbWECRKgjq//qT +9nmDOFVlSRCt2wiqPSzluwn+v+suQEBsUjTGMEd25tKXXTkNW21wIWbxeSyUoTXw +LvGS6xlwQSgNpk2qXYwf8iXg7VWZAgMBAAGjQjBAMB0GA1UdDgQWBBQgIvz0bNGJ +hjgpToksyKpP9xv9oDAPBgNVHRMECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwIBBjAN +BgkqhkiG9w0BAQsFAAOCAQEAjvjuOMDSa+JXFCLyBKsycXtBVZsJ4Ue3LbaEsPY4 +MYN/hIQ5ZM5p7EjfcnMG4CtYkNsfNHc0AhBLdq45rnT87q/6O3vUEtNMafbhU6kt +hX7Y+9XFN9NpmYxr+ekVY5xOxi8h9JDIgoMP4VB1uS0aunL1IGqrNooL9mmFnL2k +LVVee6/VR6C5+KSTCMCWppMuJIZII2v9o4dkoZ8Y7QRjQlLfYzd3qGtKbw7xaF1U +sG/5xUb/Btwb2X2g4InpiB/yt/3CpQXpiWX/K4mBvUKiGn05ZsqeY1gx4g0xLBqc +U9psmyPzK+Vsgw2jeRQ5JlKDyqE0hebfC1tvFu0CCrJFcw== +-----END CERTIFICATE----- +` + +const entersektCert = `-----BEGIN CERTIFICATE----- +MIICHjCCAcOgAwIBAgIBADAKBggqhkjOPQQDAjBvMQswCQYDVQQGEwJaQTEVMBMG +A1UECAwMV2VzdGVybiBDYXBlMRUwEwYDVQQHDAxTdGVsbGVuYm9zY2gxEjAQBgNV +BAoMCUVudGVyc2VrdDELMAkGA1UECwwCSVQxETAPBgNVBAMMCFRyYW5zYWt0MB4X +DTE0MTEwMTExMjczNFoXDTE1MTEwMTExMjczNFowbzELMAkGA1UEBhMCWkExFTAT +BgNVBAgMDFdlc3Rlcm4gQ2FwZTEVMBMGA1UEBwwMU3RlbGxlbmJvc2NoMRIwEAYD +VQQKDAlFbnRlcnNla3QxCzAJBgNVBAsMAklUMREwDwYDVQQDDAhUcmFuc2FrdDBZ +MBMGByqGSM49AgEGCCqGSM49AwEHA0IABBh10blFheMZy3k2iqW9TzLhS1DbJ/Xf +DxqQJJkpqTLq7vI+K3O4C20YtN0jsVrj7UylWoSRlPL5F7IkbeQ6aZ6jUDBOMB0G +A1UdDgQWBBQWRFF7mVAipWTdfBWk2B8Dv4Ab4jAfBgNVHSMEGDAWgBQWRFF7mVAi +pWTdfBWk2B8Dv4Ab4jAMBgNVHRMEBTADAQH/MAoGCCqGSM49BAMCA0kAMEYCIQCo +bMURXOxv6pqz6ECBh0zgL2vVhEfTOZJOW0PACGalWgIhAME0LHGi6ZS7z9yzHNqi +cnRb+okM+PIy/hBcBuqTWCbw +-----END CERTIFICATE----- +` + +func mustLoadPool(pemCerts []byte) *x509.CertPool { + p := x509.NewCertPool() + if !p.AppendCertsFromPEM(pemCerts) { + log.Fatal("u2f: Error loading root cert pool.") + return nil + } + return p +} + +var roots = mustLoadPool([]byte(yubicoRootCert + entersektCert + neowaveCert + plugUpCert)) diff --git a/vendor/github.com/tstranex/u2f/messages.go b/vendor/github.com/tstranex/u2f/messages.go new file mode 100644 index 0000000000000..a78038dea297e --- /dev/null +++ b/vendor/github.com/tstranex/u2f/messages.go @@ -0,0 +1,87 @@ +// Go FIDO U2F Library +// Copyright 2015 The Go FIDO U2F Library Authors. All rights reserved. +// Use of this source code is governed by the MIT +// license that can be found in the LICENSE file. + +package u2f + +import ( + "encoding/json" +) + +// JwkKey represents a public key used by a browser for the Channel ID TLS +// extension. +type JwkKey struct { + KTy string `json:"kty"` + Crv string `json:"crv"` + X string `json:"x"` + Y string `json:"y"` +} + +// ClientData as defined by the FIDO U2F Raw Message Formats specification. +type ClientData struct { + Typ string `json:"typ"` + Challenge string `json:"challenge"` + Origin string `json:"origin"` + CIDPubKey json.RawMessage `json:"cid_pubkey"` +} + +// RegisterRequest as defined by the FIDO U2F Javascript API 1.1. +type RegisterRequest struct { + Version string `json:"version"` + Challenge string `json:"challenge"` +} + +// WebRegisterRequest contains the parameters needed for the u2f.register() +// high-level Javascript API function as defined by the +// FIDO U2F Javascript API 1.1. +type WebRegisterRequest struct { + AppID string `json:"appId"` + RegisterRequests []RegisterRequest `json:"registerRequests"` + RegisteredKeys []RegisteredKey `json:"registeredKeys"` +} + +// RegisterResponse as defined by the FIDO U2F Javascript API 1.1. +type RegisterResponse struct { + Version string `json:"version"` + RegistrationData string `json:"registrationData"` + ClientData string `json:"clientData"` +} + +// RegisteredKey as defined by the FIDO U2F Javascript API 1.1. +type RegisteredKey struct { + Version string `json:"version"` + KeyHandle string `json:"keyHandle"` + AppID string `json:"appId"` +} + +// WebSignRequest contains the parameters needed for the u2f.sign() +// high-level Javascript API function as defined by the +// FIDO U2F Javascript API 1.1. +type WebSignRequest struct { + AppID string `json:"appId"` + Challenge string `json:"challenge"` + RegisteredKeys []RegisteredKey `json:"registeredKeys"` +} + +// SignResponse as defined by the FIDO U2F Javascript API 1.1. +type SignResponse struct { + KeyHandle string `json:"keyHandle"` + SignatureData string `json:"signatureData"` + ClientData string `json:"clientData"` +} + +// TrustedFacets as defined by the FIDO AppID and Facet Specification. +type TrustedFacets struct { + Version struct { + Major int `json:"major"` + Minor int `json:"minor"` + } `json:"version"` + Ids []string `json:"ids"` +} + +// TrustedFacetsEndpoint is a container of TrustedFacets. +// It is used as the response for an appId URL endpoint. +type TrustedFacetsEndpoint struct { + TrustedFacets []TrustedFacets `json:"trustedFacets"` +} diff --git a/vendor/github.com/tstranex/u2f/register.go b/vendor/github.com/tstranex/u2f/register.go new file mode 100644 index 0000000000000..da0c1cce246c2 --- /dev/null +++ b/vendor/github.com/tstranex/u2f/register.go @@ -0,0 +1,230 @@ +// Go FIDO U2F Library +// Copyright 2015 The Go FIDO U2F Library Authors. All rights reserved. +// Use of this source code is governed by the MIT +// license that can be found in the LICENSE file. + +package u2f + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/sha256" + "crypto/x509" + "encoding/asn1" + "encoding/hex" + "errors" + "time" +) + +// Registration represents a single enrolment or pairing between an +// application and a token. This data will typically be stored in a database. +type Registration struct { + // Raw serialized registration data as received from the token. + Raw []byte + + KeyHandle []byte + PubKey ecdsa.PublicKey + + // AttestationCert can be nil for Authenticate requests. + AttestationCert *x509.Certificate +} + +// Config contains configurable options for the package. +type Config struct { + // SkipAttestationVerify controls whether the token attestation + // certificate should be verified on registration. Ideally it should + // always be verified. However, there is currently no public list of + // trusted attestation root certificates so it may be necessary to skip. + SkipAttestationVerify bool + + // RootAttestationCertPool overrides the default root certificates used + // to verify client attestations. If nil, this defaults to the roots that are + // bundled in this library. + RootAttestationCertPool *x509.CertPool +} + +// Register validates a RegisterResponse message to enrol a new token. +// An error is returned if any part of the response fails to validate. +// The returned Registration should be stored by the caller. +func Register(resp RegisterResponse, c Challenge, config *Config) (*Registration, error) { + if config == nil { + config = &Config{} + } + + if time.Now().Sub(c.Timestamp) > timeout { + return nil, errors.New("u2f: challenge has expired") + } + + regData, err := decodeBase64(resp.RegistrationData) + if err != nil { + return nil, err + } + + clientData, err := decodeBase64(resp.ClientData) + if err != nil { + return nil, err + } + + reg, sig, err := parseRegistration(regData) + if err != nil { + return nil, err + } + + if err := verifyClientData(clientData, c); err != nil { + return nil, err + } + + if err := verifyAttestationCert(*reg, config); err != nil { + return nil, err + } + + if err := verifyRegistrationSignature(*reg, sig, c.AppID, clientData); err != nil { + return nil, err + } + + return reg, nil +} + +func parseRegistration(buf []byte) (*Registration, []byte, error) { + if len(buf) < 1+65+1+1+1 { + return nil, nil, errors.New("u2f: data is too short") + } + + var r Registration + r.Raw = buf + + if buf[0] != 0x05 { + return nil, nil, errors.New("u2f: invalid reserved byte") + } + buf = buf[1:] + + x, y := elliptic.Unmarshal(elliptic.P256(), buf[:65]) + if x == nil { + return nil, nil, errors.New("u2f: invalid public key") + } + r.PubKey.Curve = elliptic.P256() + r.PubKey.X = x + r.PubKey.Y = y + buf = buf[65:] + + khLen := int(buf[0]) + buf = buf[1:] + if len(buf) < khLen { + return nil, nil, errors.New("u2f: invalid key handle") + } + r.KeyHandle = buf[:khLen] + buf = buf[khLen:] + + // The length of the x509 cert isn't specified so it has to be inferred + // by parsing. We can't use x509.ParseCertificate yet because it returns + // an error if there are any trailing bytes. So parse raw asn1 as a + // workaround to get the length. + sig, err := asn1.Unmarshal(buf, &asn1.RawValue{}) + if err != nil { + return nil, nil, err + } + + buf = buf[:len(buf)-len(sig)] + fixCertIfNeed(buf) + cert, err := x509.ParseCertificate(buf) + if err != nil { + return nil, nil, err + } + r.AttestationCert = cert + + return &r, sig, nil +} + +// UnmarshalBinary implements encoding.BinaryMarshaler. +func (r *Registration) UnmarshalBinary(data []byte) error { + reg, _, err := parseRegistration(data) + if err != nil { + return err + } + *r = *reg + return nil +} + +// MarshalBinary implements encoding.BinaryUnmarshaler. +func (r *Registration) MarshalBinary() ([]byte, error) { + return r.Raw, nil +} + +func verifyAttestationCert(r Registration, config *Config) error { + if config.SkipAttestationVerify { + return nil + } + rootCertPool := roots + if config.RootAttestationCertPool != nil { + rootCertPool = config.RootAttestationCertPool + } + + opts := x509.VerifyOptions{Roots: rootCertPool} + _, err := r.AttestationCert.Verify(opts) + return err +} + +func verifyRegistrationSignature( + r Registration, signature []byte, appid string, clientData []byte) error { + + appParam := sha256.Sum256([]byte(appid)) + challenge := sha256.Sum256(clientData) + + buf := []byte{0} + buf = append(buf, appParam[:]...) + buf = append(buf, challenge[:]...) + buf = append(buf, r.KeyHandle...) + pk := elliptic.Marshal(r.PubKey.Curve, r.PubKey.X, r.PubKey.Y) + buf = append(buf, pk...) + + return r.AttestationCert.CheckSignature( + x509.ECDSAWithSHA256, buf, signature) +} + +func getRegisteredKey(appID string, r Registration) RegisteredKey { + return RegisteredKey{ + Version: u2fVersion, + KeyHandle: encodeBase64(r.KeyHandle), + AppID: appID, + } +} + +// fixCertIfNeed fixes broken certificates described in +// https://github.com/Yubico/php-u2flib-server/blob/master/src/u2flib_server/U2F.php#L84 +func fixCertIfNeed(cert []byte) { + h := sha256.Sum256(cert) + switch hex.EncodeToString(h[:]) { + case + "349bca1031f8c82c4ceca38b9cebf1a69df9fb3b94eed99eb3fb9aa3822d26e8", + "dd574527df608e47ae45fbba75a2afdd5c20fd94a02419381813cd55a2a3398f", + "1d8764f0f7cd1352df6150045c8f638e517270e8b5dda1c63ade9c2280240cae", + "d0edc9a91a1677435a953390865d208c55b3183c6759c9b5a7ff494c322558eb", + "6073c436dcd064a48127ddbf6032ac1a66fd59a0c24434f070d4e564c124c897", + "ca993121846c464d666096d35f13bf44c1b05af205f9b4a1e00cf6cc10c5e511": + + // clear the offending byte. + cert[len(cert)-257] = 0 + } +} + +// NewWebRegisterRequest creates a request to enrol a new token. +// regs is the list of the user's existing registration. The browser will +// refuse to re-register a device if it has an existing registration. +func NewWebRegisterRequest(c *Challenge, regs []Registration) *WebRegisterRequest { + req := RegisterRequest{ + Version: u2fVersion, + Challenge: encodeBase64(c.Challenge), + } + + rr := WebRegisterRequest{ + AppID: c.AppID, + RegisterRequests: []RegisterRequest{req}, + } + + for _, r := range regs { + rk := getRegisteredKey(c.AppID, r) + rr.RegisteredKeys = append(rr.RegisteredKeys, rk) + } + + return &rr +} diff --git a/vendor/github.com/tstranex/u2f/util.go b/vendor/github.com/tstranex/u2f/util.go new file mode 100644 index 0000000000000..f035aa417bffe --- /dev/null +++ b/vendor/github.com/tstranex/u2f/util.go @@ -0,0 +1,125 @@ +// Go FIDO U2F Library +// Copyright 2015 The Go FIDO U2F Library Authors. All rights reserved. +// Use of this source code is governed by the MIT +// license that can be found in the LICENSE file. + +/* +Package u2f implements the server-side parts of the +FIDO Universal 2nd Factor (U2F) specification. + +Applications will usually persist Challenge and Registration objects in a +database. + +To enrol a new token: + + app_id := "http://localhost" + c, _ := NewChallenge(app_id, []string{app_id}) + req, _ := u2f.NewWebRegisterRequest(c, existingTokens) + // Send the request to the browser. + var resp RegisterResponse + // Read resp from the browser. + reg, err := Register(resp, c) + if err != nil { + // Registration failed. + } + // Store reg in the database. + +To perform an authentication: + + var regs []Registration + // Fetch regs from the database. + c, _ := NewChallenge(app_id, []string{app_id}) + req, _ := c.SignRequest(regs) + // Send the request to the browser. + var resp SignResponse + // Read resp from the browser. + new_counter, err := reg.Authenticate(resp, c) + if err != nil { + // Authentication failed. + } + reg.Counter = new_counter + // Store updated Registration in the database. + +The FIDO U2F specification can be found here: +https://fidoalliance.org/specifications/download +*/ +package u2f + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "errors" + "strings" + "time" +) + +const u2fVersion = "U2F_V2" +const timeout = 5 * time.Minute + +func decodeBase64(s string) ([]byte, error) { + for i := 0; i < len(s)%4; i++ { + s += "=" + } + return base64.URLEncoding.DecodeString(s) +} + +func encodeBase64(buf []byte) string { + s := base64.URLEncoding.EncodeToString(buf) + return strings.TrimRight(s, "=") +} + +// Challenge represents a single transaction between the server and +// authenticator. This data will typically be stored in a database. +type Challenge struct { + Challenge []byte + Timestamp time.Time + AppID string + TrustedFacets []string +} + +// NewChallenge generates a challenge for the given application. +func NewChallenge(appID string, trustedFacets []string) (*Challenge, error) { + challenge := make([]byte, 32) + n, err := rand.Read(challenge) + if err != nil { + return nil, err + } + if n != 32 { + return nil, errors.New("u2f: unable to generate random bytes") + } + + var c Challenge + c.Challenge = challenge + c.Timestamp = time.Now() + c.AppID = appID + c.TrustedFacets = trustedFacets + return &c, nil +} + +func verifyClientData(clientData []byte, challenge Challenge) error { + var cd ClientData + if err := json.Unmarshal(clientData, &cd); err != nil { + return err + } + + foundFacetID := false + for _, facetID := range challenge.TrustedFacets { + if facetID == cd.Origin { + foundFacetID = true + break + } + } + if !foundFacetID { + return errors.New("u2f: untrusted facet id") + } + + c := encodeBase64(challenge.Challenge) + if len(c) != len(cd.Challenge) || + subtle.ConstantTimeCompare([]byte(c), []byte(cd.Challenge)) != 1 { + return errors.New("u2f: challenge does not match") + } + + return nil +} From 18b3f090e32161ae1f6f602e94e9785f0c74159e Mon Sep 17 00:00:00 2001 From: Jonas Franz Date: Tue, 15 May 2018 23:04:46 +0200 Subject: [PATCH 03/11] Minor improvements Signed-off-by: Jonas Franz --- models/u2f.go | 4 ++++ routers/routes/routes.go | 4 ++-- routers/user/auth.go | 2 +- routers/user/setting.go | 12 ++++++------ 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/models/u2f.go b/models/u2f.go index a015a75d0e2c2..6a1c908697a75 100644 --- a/models/u2f.go +++ b/models/u2f.go @@ -1,3 +1,7 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + package models import ( diff --git a/routers/routes/routes.go b/routers/routes/routes.go index 927c6ff2795fa..d20aaeb0696b2 100644 --- a/routers/routes/routes.go +++ b/routers/routes/routes.go @@ -6,6 +6,7 @@ package routes import ( "encoding/gob" + "net/http" "os" "path" "time" @@ -28,7 +29,6 @@ import ( "code.gitea.io/gitea/routers/private" "code.gitea.io/gitea/routers/repo" "code.gitea.io/gitea/routers/user" - "github.com/tstranex/u2f" "github.com/go-macaron/binding" "github.com/go-macaron/cache" @@ -38,8 +38,8 @@ import ( "github.com/go-macaron/i18n" "github.com/go-macaron/session" "github.com/go-macaron/toolbox" + "github.com/tstranex/u2f" "gopkg.in/macaron.v1" - "net/http" ) // NewMacaron initializes Macaron instance. diff --git a/routers/user/auth.go b/routers/user/auth.go index 6502963f54e7e..4f00a9087e92e 100644 --- a/routers/user/auth.go +++ b/routers/user/auth.go @@ -18,10 +18,10 @@ import ( "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" - "github.com/tstranex/u2f" "github.com/go-macaron/captcha" "github.com/markbates/goth" + "github.com/tstranex/u2f" ) const ( diff --git a/routers/user/setting.go b/routers/user/setting.go index 0bfd6e5b164a0..7681d76b15037 100644 --- a/routers/user/setting.go +++ b/routers/user/setting.go @@ -14,18 +14,18 @@ import ( "io/ioutil" "strings" - "github.com/Unknwon/com" - "github.com/Unknwon/i18n" - "github.com/pquerna/otp" - "github.com/pquerna/otp/totp" - "github.com/tstranex/u2f" - "code.gitea.io/gitea/models" "code.gitea.io/gitea/modules/auth" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" + + "github.com/Unknwon/com" + "github.com/Unknwon/i18n" + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" + "github.com/tstranex/u2f" ) const ( From acf67281e1a7bf4e3f57ab363ce736e3f6663384 Mon Sep 17 00:00:00 2001 From: Jonas Franz Date: Wed, 16 May 2018 15:35:36 +0200 Subject: [PATCH 04/11] Add U2F support for Firefox, Chrome (Android) by introducing a custom JS library Add U2F error handling Signed-off-by: Jonas Franz --- options/locale/locale_en-US.ini | 8 + public/js/index.js | 137 ++-- public/vendor/librejs.html | 6 +- public/vendor/plugins/u2f/index.js | 1 + public/vendor/plugins/u2f/u2f-api.js | 748 ---------------------- templates/base/footer.tmpl | 2 +- templates/user/auth/u2f.tmpl | 1 + templates/user/auth/u2f_error.tmpl | 32 + templates/user/settings/security_u2f.tmpl | 2 + 9 files changed, 136 insertions(+), 801 deletions(-) create mode 100644 public/vendor/plugins/u2f/index.js delete mode 100644 public/vendor/plugins/u2f/u2f-api.js create mode 100644 templates/user/auth/u2f_error.tmpl diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 4a216745aeac4..bfe8ff27b2c5d 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -35,6 +35,14 @@ u2f_insert_key = Insert your security key u2f_sign_in = Press the button on your security key. If you can't find a button, re-insert it. u2f_press_button = Please press the button on your security key… u2f_use_twofa = Use a two-factor code from your phone +u2f_error = We can't read your security key! +u2f_unsupported_browser = Your browser don't support U2F keys. Please try another browser. +u2f_error_1 = An unknown error occured. Please retry. +u2f_error_2 = Please make sure that you're using an encrypted connection (https://) and visiting the correct URL. +u2f_error_3 = The server could not proceed your request. +u2f_error_4 = The presented key is not eligible for this request. If you try to register it, make sure that the key isn't already registered. +u2f_error_5 = Timeout reached before your key could be read. Please reload to retry. +u2f_reload = Reload repository = Repository organization = Organization diff --git a/public/js/index.js b/public/js/index.js index 98f353604ef8c..f416db072dc77 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -1436,14 +1436,26 @@ function initU2FAuth() { if($('#wait-for-key').length === 0) { return } - - $.getJSON('/user/u2f/challenge').success(function(req) { - console.log(req); - u2f.sign(req.appId, req.challenge, req.registeredKeys, u2fSigned, 30); - }); + u2fApi.ensureSupport() + .then(function () { + $.getJSON('/user/u2f/challenge').success(function(req) { + console.log(req); + u2fApi.sign(req.appId, req.challenge, req.registeredKeys, 30) + .then(u2fSigned) + .catch(function (err) { + if(err === undefined) { + u2fError(1); + return + } + u2fError(err.metaData.code); + }); + }); + }).catch(function () { + // Fallback in case browser do not support U2F + window.location.href = "/user/two_factor" + }) } function u2fSigned(resp) { - console.log("u2fSigned", resp); $.ajax({ url:'/user/u2f/sign', type:"POST", @@ -1451,11 +1463,9 @@ function u2fSigned(resp) { data: JSON.stringify(resp), contentType:"application/json; charset=utf-8", }).done(function(res){ - console.log(res); window.location.replace(res); }).fail(function (xhr, textStatus) { - // TODO error handling - console.log(xhr); + u2fError(1); }); } @@ -1463,63 +1473,92 @@ function u2fRegistered(resp) { if (checkError(resp)) { return; } - if(u2f !== undefined){ - $.ajax({ - url:'/user/settings/security/u2f/register', - type:"POST", - headers: {"X-Csrf-Token": csrf}, - data: JSON.stringify(resp), - contentType:"application/json; charset=utf-8", - success: function(){ - window.location.reload(); - }, - fail: function (xhr, textStatus) { - console.log(xhr); - } - }); - } + console.log(resp); + $.ajax({ + url:'/user/settings/security/u2f/register', + type:"POST", + headers: {"X-Csrf-Token": csrf}, + data: JSON.stringify(resp), + contentType:"application/json; charset=utf-8", + success: function(){ + window.location.reload(); + }, + fail: function (xhr, textStatus) { + u2fError(1); + } + }); } -// TODO better error handling function checkError(resp) { if (!('errorCode' in resp)) { return false; } - if (resp.errorCode === u2f.ErrorCodes['OK']) { + if (resp.errorCode === 0) { return false; } - var msg = 'U2F error code ' + resp.errorCode; - for (var code_name in u2f.ErrorCodes) { - if (u2f.ErrorCodes[code_name] === resp.errorCode) { - msg += ' (' + code_name + ')'; + u2fError(resp.errorCode); + return true; +} + + +function u2fError(errorType) { + var u2fErrors = { + 'browser': $('#unsupported-browser'), + 1: $('#u2f-error-1'), + 2: $('#u2f-error-2'), + 3: $('#u2f-error-3'), + 4: $('#u2f-error-4'), + 5: $('.u2f-error-5') + }; + u2fErrors[errorType].removeClass('hide'); + for(var type in u2fErrors){ + if(type != errorType){ + u2fErrors[type].addClass('hide'); } } - if (resp.errorMessage) { - msg += ': ' + resp.errorMessage; - } - console.log(msg); - return true; + $('#u2f-error').modal('show'); } + function initU2FRegister() { + $('#register-device').modal({allowMultiple: false}); + $('#u2f-error').modal({allowMultiple: false}); $('#register-security-key').on('click', function(e) { e.preventDefault(); - $.post("/user/settings/security/u2f/request_register", { - "_csrf": csrf, - "name": $('#nickname').val() - }).success(function(req) { - $('#register-device').modal('show'); - if(req.registeredKeys === null) { - req.registeredKeys = [] - } - u2f.register(req.appId, req.registerRequests, req.registeredKeys, u2fRegistered, 30); - }).fail(function(xhr, status, error) { - if(xhr.status === 409) { - $("#nickname").closest("div.field").addClass("error"); - } - }); + u2fApi.ensureSupport() + .then(u2fRegisterRequest) + .catch(function() { + u2fError('browser'); + }) }) } +function u2fRegisterRequest() { + $.post("/user/settings/security/u2f/request_register", { + "_csrf": csrf, + "name": $('#nickname').val() + }).success(function(req) { + $("#nickname").closest("div.field").removeClass("error"); + $('#register-device').modal('show'); + if(req.registeredKeys === null) { + req.registeredKeys = [] + } + console.log(req); + u2fApi.register(req.appId, req.registerRequests, req.registeredKeys, 30) + .then(u2fRegistered) + .catch(function (reason) { + if(reason === undefined) { + u2fError(1); + return + } + u2fError(reason.metaData.code); + }); + }).fail(function(xhr, status, error) { + if(xhr.status === 409) { + $("#nickname").closest("div.field").addClass("error"); + } + }); +} + $(document).ready(function () { csrf = $('meta[name=_csrf]').attr("content"); suburl = $('meta[name=_suburl]').attr("content"); diff --git a/public/vendor/librejs.html b/public/vendor/librejs.html index 5381850b8553b..e24bb9c3ebf55 100644 --- a/public/vendor/librejs.html +++ b/public/vendor/librejs.html @@ -111,9 +111,9 @@ pdf.js-v1.4.20.tar.gz - u2f-ref-code - BSD 3-Clause - u2f-ref-code-master.zip + u2f-api + Expat + u2f-api-1.0.8.zip font-awesome - fonts diff --git a/public/vendor/plugins/u2f/index.js b/public/vendor/plugins/u2f/index.js new file mode 100644 index 0000000000000..1413f51d7b060 --- /dev/null +++ b/public/vendor/plugins/u2f/index.js @@ -0,0 +1 @@ +this.u2fApi=function(e){var t={};function r(o){if(t[o])return t[o].exports;var n=t[o]={i:o,l:!1,exports:{}};return e[o].call(n.exports,n,n.exports,r),n.l=!0,n.exports}return r.m=e,r.c=t,r.d=function(e,t,o){r.o(e,t)||Object.defineProperty(e,t,{configurable:!1,enumerable:!0,get:o})},r.r=function(e){Object.defineProperty(e,"__esModule",{value:!0})},r.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return r.d(t,"a",t),t},r.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},r.p="",r(r.s=1)}([function(e,t,r){"use strict";var o,n=n||{};e.exports=n,n.EXTENSION_ID="kmendfapggjehodndflmmgagdbamhnfd",n.MessageTypes={U2F_REGISTER_REQUEST:"u2f_register_request",U2F_REGISTER_RESPONSE:"u2f_register_response",U2F_SIGN_REQUEST:"u2f_sign_request",U2F_SIGN_RESPONSE:"u2f_sign_response",U2F_GET_API_VERSION_REQUEST:"u2f_get_api_version_request",U2F_GET_API_VERSION_RESPONSE:"u2f_get_api_version_response"},n.ErrorCodes={OK:0,OTHER_ERROR:1,BAD_REQUEST:2,CONFIGURATION_UNSUPPORTED:3,DEVICE_INELIGIBLE:4,TIMEOUT:5},n.U2fRequest,n.U2fResponse,n.Error,n.Transport,n.Transports,n.SignRequest,n.SignResponse,n.RegisterRequest,n.RegisterResponse,n.RegisteredKey,n.GetJsApiVersionResponse,n.getMessagePort=function(e){if("undefined"!=typeof chrome&&chrome.runtime){var t={type:n.MessageTypes.U2F_SIGN_REQUEST,signRequests:[]};chrome.runtime.sendMessage(n.EXTENSION_ID,t,function(){chrome.runtime.lastError?n.getIframePort_(e):n.getChromeRuntimePort_(e)})}else n.isAndroidChrome_()?n.getAuthenticatorPort_(e):n.isIosChrome_()?n.getIosPort_(e):n.getIframePort_(e)},n.isAndroidChrome_=function(){var e=navigator.userAgent;return-1!=e.indexOf("Chrome")&&-1!=e.indexOf("Android")},n.isIosChrome_=function(){return["iPhone","iPad","iPod"].indexOf(navigator.platform)>-1},n.getChromeRuntimePort_=function(e){var t=chrome.runtime.connect(n.EXTENSION_ID,{includeTlsChannelId:!0});setTimeout(function(){e(new n.WrappedChromeRuntimePort_(t))},0)},n.getAuthenticatorPort_=function(e){setTimeout(function(){e(new n.WrappedAuthenticatorPort_)},0)},n.getIosPort_=function(e){setTimeout(function(){e(new n.WrappedIosPort_)},0)},n.WrappedChromeRuntimePort_=function(e){this.port_=e},n.formatSignRequest_=function(e,t,r,s,i){if(void 0===o||o<1.1){for(var a=[],u=0;u} - */ -u2f.Transports; - -/** - * Data object for a single sign request. - * @typedef {{ - * version: string, - * challenge: string, - * keyHandle: string, - * appId: string - * }} - */ -u2f.SignRequest; - - -/** - * Data object for a sign response. - * @typedef {{ - * keyHandle: string, - * signatureData: string, - * clientData: string - * }} - */ -u2f.SignResponse; - - -/** - * Data object for a registration request. - * @typedef {{ - * version: string, - * challenge: string - * }} - */ -u2f.RegisterRequest; - - -/** - * Data object for a registration response. - * @typedef {{ - * version: string, - * keyHandle: string, - * transports: Transports, - * appId: string - * }} - */ -u2f.RegisterResponse; - - -/** - * Data object for a registered key. - * @typedef {{ - * version: string, - * keyHandle: string, - * transports: ?Transports, - * appId: ?string - * }} - */ -u2f.RegisteredKey; - - -/** - * Data object for a get API register response. - * @typedef {{ - * js_api_version: number - * }} - */ -u2f.GetJsApiVersionResponse; - - -//Low level MessagePort API support - -/** - * Sets up a MessagePort to the U2F extension using the - * available mechanisms. - * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback - */ -u2f.getMessagePort = function(callback) { - if (typeof chrome != 'undefined' && chrome.runtime) { - // The actual message here does not matter, but we need to get a reply - // for the callback to run. Thus, send an empty signature request - // in order to get a failure response. - var msg = { - type: u2f.MessageTypes.U2F_SIGN_REQUEST, - signRequests: [] - }; - chrome.runtime.sendMessage(u2f.EXTENSION_ID, msg, function() { - if (!chrome.runtime.lastError) { - // We are on a whitelisted origin and can talk directly - // with the extension. - u2f.getChromeRuntimePort_(callback); - } else { - // chrome.runtime was available, but we couldn't message - // the extension directly, use iframe - u2f.getIframePort_(callback); - } - }); - } else if (u2f.isAndroidChrome_()) { - u2f.getAuthenticatorPort_(callback); - } else if (u2f.isIosChrome_()) { - u2f.getIosPort_(callback); - } else { - // chrome.runtime was not available at all, which is normal - // when this origin doesn't have access to any extensions. - u2f.getIframePort_(callback); - } -}; - -/** - * Detect chrome running on android based on the browser's useragent. - * @private - */ -u2f.isAndroidChrome_ = function() { - var userAgent = navigator.userAgent; - return userAgent.indexOf('Chrome') != -1 && - userAgent.indexOf('Android') != -1; -}; - -/** - * Detect chrome running on iOS based on the browser's platform. - * @private - */ -u2f.isIosChrome_ = function() { - return ["iPhone", "iPad", "iPod"].indexOf(navigator.platform) > -1; -}; - -/** - * Connects directly to the extension via chrome.runtime.connect. - * @param {function(u2f.WrappedChromeRuntimePort_)} callback - * @private - */ -u2f.getChromeRuntimePort_ = function(callback) { - var port = chrome.runtime.connect(u2f.EXTENSION_ID, - {'includeTlsChannelId': true}); - setTimeout(function() { - callback(new u2f.WrappedChromeRuntimePort_(port)); - }, 0); -}; - -/** - * Return a 'port' abstraction to the Authenticator app. - * @param {function(u2f.WrappedAuthenticatorPort_)} callback - * @private - */ -u2f.getAuthenticatorPort_ = function(callback) { - setTimeout(function() { - callback(new u2f.WrappedAuthenticatorPort_()); - }, 0); -}; - -/** - * Return a 'port' abstraction to the iOS client app. - * @param {function(u2f.WrappedIosPort_)} callback - * @private - */ -u2f.getIosPort_ = function(callback) { - setTimeout(function() { - callback(new u2f.WrappedIosPort_()); - }, 0); -}; - -/** - * A wrapper for chrome.runtime.Port that is compatible with MessagePort. - * @param {Port} port - * @constructor - * @private - */ -u2f.WrappedChromeRuntimePort_ = function(port) { - this.port_ = port; -}; - -/** - * Format and return a sign request compliant with the JS API version supported by the extension. - * @param {Array} signRequests - * @param {number} timeoutSeconds - * @param {number} reqId - * @return {Object} - */ -u2f.formatSignRequest_ = - function(appId, challenge, registeredKeys, timeoutSeconds, reqId) { - if (js_api_version === undefined || js_api_version < 1.1) { - // Adapt request to the 1.0 JS API - var signRequests = []; - for (var i = 0; i < registeredKeys.length; i++) { - signRequests[i] = { - version: registeredKeys[i].version, - challenge: challenge, - keyHandle: registeredKeys[i].keyHandle, - appId: appId - }; - } - return { - type: u2f.MessageTypes.U2F_SIGN_REQUEST, - signRequests: signRequests, - timeoutSeconds: timeoutSeconds, - requestId: reqId - }; - } - // JS 1.1 API - return { - type: u2f.MessageTypes.U2F_SIGN_REQUEST, - appId: appId, - challenge: challenge, - registeredKeys: registeredKeys, - timeoutSeconds: timeoutSeconds, - requestId: reqId - }; - }; - -/** - * Format and return a register request compliant with the JS API version supported by the extension.. - * @param {Array} signRequests - * @param {Array} signRequests - * @param {number} timeoutSeconds - * @param {number} reqId - * @return {Object} - */ -u2f.formatRegisterRequest_ = - function(appId, registeredKeys, registerRequests, timeoutSeconds, reqId) { - if (js_api_version === undefined || js_api_version < 1.1) { - // Adapt request to the 1.0 JS API - for (var i = 0; i < registerRequests.length; i++) { - registerRequests[i].appId = appId; - } - var signRequests = []; - for (var i = 0; i < registeredKeys.length; i++) { - signRequests[i] = { - version: registeredKeys[i].version, - challenge: registerRequests[0], - keyHandle: registeredKeys[i].keyHandle, - appId: appId - }; - } - return { - type: u2f.MessageTypes.U2F_REGISTER_REQUEST, - signRequests: signRequests, - registerRequests: registerRequests, - timeoutSeconds: timeoutSeconds, - requestId: reqId - }; - } - // JS 1.1 API - return { - type: u2f.MessageTypes.U2F_REGISTER_REQUEST, - appId: appId, - registerRequests: registerRequests, - registeredKeys: registeredKeys, - timeoutSeconds: timeoutSeconds, - requestId: reqId - }; - }; - - -/** - * Posts a message on the underlying channel. - * @param {Object} message - */ -u2f.WrappedChromeRuntimePort_.prototype.postMessage = function(message) { - this.port_.postMessage(message); -}; - - -/** - * Emulates the HTML 5 addEventListener interface. Works only for the - * onmessage event, which is hooked up to the chrome.runtime.Port.onMessage. - * @param {string} eventName - * @param {function({data: Object})} handler - */ -u2f.WrappedChromeRuntimePort_.prototype.addEventListener = - function(eventName, handler) { - var name = eventName.toLowerCase(); - if (name == 'message' || name == 'onmessage') { - this.port_.onMessage.addListener(function(message) { - // Emulate a minimal MessageEvent object - handler({'data': message}); - }); - } else { - console.error('WrappedChromeRuntimePort only supports onMessage'); - } - }; - -/** - * Wrap the Authenticator app with a MessagePort interface. - * @constructor - * @private - */ -u2f.WrappedAuthenticatorPort_ = function() { - this.requestId_ = -1; - this.requestObject_ = null; -} - -/** - * Launch the Authenticator intent. - * @param {Object} message - */ -u2f.WrappedAuthenticatorPort_.prototype.postMessage = function(message) { - var intentUrl = - u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ + - ';S.request=' + encodeURIComponent(JSON.stringify(message)) + - ';end'; - document.location = intentUrl; -}; - -/** - * Tells what type of port this is. - * @return {String} port type - */ -u2f.WrappedAuthenticatorPort_.prototype.getPortType = function() { - return "WrappedAuthenticatorPort_"; -}; - - -/** - * Emulates the HTML 5 addEventListener interface. - * @param {string} eventName - * @param {function({data: Object})} handler - */ -u2f.WrappedAuthenticatorPort_.prototype.addEventListener = function(eventName, handler) { - var name = eventName.toLowerCase(); - if (name == 'message') { - var self = this; - /* Register a callback to that executes when - * chrome injects the response. */ - window.addEventListener( - 'message', self.onRequestUpdate_.bind(self, handler), false); - } else { - console.error('WrappedAuthenticatorPort only supports message'); - } -}; - -/** - * Callback invoked when a response is received from the Authenticator. - * @param function({data: Object}) callback - * @param {Object} message message Object - */ -u2f.WrappedAuthenticatorPort_.prototype.onRequestUpdate_ = - function(callback, message) { - var messageObject = JSON.parse(message.data); - var intentUrl = messageObject['intentURL']; - - var errorCode = messageObject['errorCode']; - var responseObject = null; - if (messageObject.hasOwnProperty('data')) { - responseObject = /** @type {Object} */ ( - JSON.parse(messageObject['data'])); - } - - callback({'data': responseObject}); - }; - -/** - * Base URL for intents to Authenticator. - * @const - * @private - */ -u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ = - 'intent:#Intent;action=com.google.android.apps.authenticator.AUTHENTICATE'; - -/** - * Wrap the iOS client app with a MessagePort interface. - * @constructor - * @private - */ -u2f.WrappedIosPort_ = function() {}; - -/** - * Launch the iOS client app request - * @param {Object} message - */ -u2f.WrappedIosPort_.prototype.postMessage = function(message) { - var str = JSON.stringify(message); - var url = "u2f://auth?" + encodeURI(str); - location.replace(url); -}; - -/** - * Tells what type of port this is. - * @return {String} port type - */ -u2f.WrappedIosPort_.prototype.getPortType = function() { - return "WrappedIosPort_"; -}; - -/** - * Emulates the HTML 5 addEventListener interface. - * @param {string} eventName - * @param {function({data: Object})} handler - */ -u2f.WrappedIosPort_.prototype.addEventListener = function(eventName, handler) { - var name = eventName.toLowerCase(); - if (name !== 'message') { - console.error('WrappedIosPort only supports message'); - } -}; - -/** - * Sets up an embedded trampoline iframe, sourced from the extension. - * @param {function(MessagePort)} callback - * @private - */ -u2f.getIframePort_ = function(callback) { - // Create the iframe - var iframeOrigin = 'chrome-extension://' + u2f.EXTENSION_ID; - var iframe = document.createElement('iframe'); - iframe.src = iframeOrigin + '/u2f-comms.html'; - iframe.setAttribute('style', 'display:none'); - document.body.appendChild(iframe); - - var channel = new MessageChannel(); - var ready = function(message) { - if (message.data == 'ready') { - channel.port1.removeEventListener('message', ready); - callback(channel.port1); - } else { - console.error('First event on iframe port was not "ready"'); - } - }; - channel.port1.addEventListener('message', ready); - channel.port1.start(); - - iframe.addEventListener('load', function() { - // Deliver the port to the iframe and initialize - iframe.contentWindow.postMessage('init', iframeOrigin, [channel.port2]); - }); -}; - - -//High-level JS API - -/** - * Default extension response timeout in seconds. - * @const - */ -u2f.EXTENSION_TIMEOUT_SEC = 30; - -/** - * A singleton instance for a MessagePort to the extension. - * @type {MessagePort|u2f.WrappedChromeRuntimePort_} - * @private - */ -u2f.port_ = null; - -/** - * Callbacks waiting for a port - * @type {Array} - * @private - */ -u2f.waitingForPort_ = []; - -/** - * A counter for requestIds. - * @type {number} - * @private - */ -u2f.reqCounter_ = 0; - -/** - * A map from requestIds to client callbacks - * @type {Object.} - * @private - */ -u2f.callbackMap_ = {}; - -/** - * Creates or retrieves the MessagePort singleton to use. - * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback - * @private - */ -u2f.getPortSingleton_ = function(callback) { - if (u2f.port_) { - callback(u2f.port_); - } else { - if (u2f.waitingForPort_.length == 0) { - u2f.getMessagePort(function(port) { - u2f.port_ = port; - u2f.port_.addEventListener('message', - /** @type {function(Event)} */ (u2f.responseHandler_)); - - // Careful, here be async callbacks. Maybe. - while (u2f.waitingForPort_.length) - u2f.waitingForPort_.shift()(u2f.port_); - }); - } - u2f.waitingForPort_.push(callback); - } -}; - -/** - * Handles response messages from the extension. - * @param {MessageEvent.} message - * @private - */ -u2f.responseHandler_ = function(message) { - var response = message.data; - var reqId = response['requestId']; - if (!reqId || !u2f.callbackMap_[reqId]) { - console.error('Unknown or missing requestId in response.'); - return; - } - var cb = u2f.callbackMap_[reqId]; - delete u2f.callbackMap_[reqId]; - cb(response['responseData']); -}; - -/** - * Dispatches an array of sign requests to available U2F tokens. - * If the JS API version supported by the extension is unknown, it first sends a - * message to the extension to find out the supported API version and then it sends - * the sign request. - * @param {string=} appId - * @param {string=} challenge - * @param {Array} registeredKeys - * @param {function((u2f.Error|u2f.SignResponse))} callback - * @param {number=} opt_timeoutSeconds - */ -u2f.sign = function(appId, challenge, registeredKeys, callback, opt_timeoutSeconds) { - if (js_api_version === undefined) { - // Send a message to get the extension to JS API version, then send the actual sign request. - u2f.getApiVersion( - function (response) { - js_api_version = response['js_api_version'] === undefined ? 0 : response['js_api_version']; - console.log("Extension JS API Version: ", js_api_version); - u2f.sendSignRequest(appId, challenge, registeredKeys, callback, opt_timeoutSeconds); - }); - } else { - // We know the JS API version. Send the actual sign request in the supported API version. - u2f.sendSignRequest(appId, challenge, registeredKeys, callback, opt_timeoutSeconds); - } -}; - -/** - * Dispatches an array of sign requests to available U2F tokens. - * @param {string=} appId - * @param {string=} challenge - * @param {Array} registeredKeys - * @param {function((u2f.Error|u2f.SignResponse))} callback - * @param {number=} opt_timeoutSeconds - */ -u2f.sendSignRequest = function(appId, challenge, registeredKeys, callback, opt_timeoutSeconds) { - u2f.getPortSingleton_(function(port) { - var reqId = ++u2f.reqCounter_; - u2f.callbackMap_[reqId] = callback; - var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ? - opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC); - var req = u2f.formatSignRequest_(appId, challenge, registeredKeys, timeoutSeconds, reqId); - port.postMessage(req); - }); -}; - -/** - * Dispatches register requests to available U2F tokens. An array of sign - * requests identifies already registered tokens. - * If the JS API version supported by the extension is unknown, it first sends a - * message to the extension to find out the supported API version and then it sends - * the register request. - * @param {string=} appId - * @param {Array} registerRequests - * @param {Array} registeredKeys - * @param {function((u2f.Error|u2f.RegisterResponse))} callback - * @param {number=} opt_timeoutSeconds - */ -u2f.register = function(appId, registerRequests, registeredKeys, callback, opt_timeoutSeconds) { - if (js_api_version === undefined) { - // Send a message to get the extension to JS API version, then send the actual register request. - u2f.getApiVersion( - function (response) { - js_api_version = response['js_api_version'] === undefined ? 0: response['js_api_version']; - console.log("Extension JS API Version: ", js_api_version); - u2f.sendRegisterRequest(appId, registerRequests, registeredKeys, - callback, opt_timeoutSeconds); - }); - } else { - // We know the JS API version. Send the actual register request in the supported API version. - u2f.sendRegisterRequest(appId, registerRequests, registeredKeys, - callback, opt_timeoutSeconds); - } -}; - -/** - * Dispatches register requests to available U2F tokens. An array of sign - * requests identifies already registered tokens. - * @param {string=} appId - * @param {Array} registerRequests - * @param {Array} registeredKeys - * @param {function((u2f.Error|u2f.RegisterResponse))} callback - * @param {number=} opt_timeoutSeconds - */ -u2f.sendRegisterRequest = function(appId, registerRequests, registeredKeys, callback, opt_timeoutSeconds) { - u2f.getPortSingleton_(function(port) { - var reqId = ++u2f.reqCounter_; - u2f.callbackMap_[reqId] = callback; - var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ? - opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC); - var req = u2f.formatRegisterRequest_( - appId, registeredKeys, registerRequests, timeoutSeconds, reqId); - port.postMessage(req); - }); -}; - - -/** - * Dispatches a message to the extension to find out the supported - * JS API version. - * If the user is on a mobile phone and is thus using Google Authenticator instead - * of the Chrome extension, don't send the request and simply return 0. - * @param {function((u2f.Error|u2f.GetJsApiVersionResponse))} callback - * @param {number=} opt_timeoutSeconds - */ -u2f.getApiVersion = function(callback, opt_timeoutSeconds) { - u2f.getPortSingleton_(function(port) { - // If we are using Android Google Authenticator or iOS client app, - // do not fire an intent to ask which JS API version to use. - if (port.getPortType) { - var apiVersion; - switch (port.getPortType()) { - case 'WrappedIosPort_': - case 'WrappedAuthenticatorPort_': - apiVersion = 1.1; - break; - - default: - apiVersion = 0; - break; - } - callback({ 'js_api_version': apiVersion }); - return; - } - var reqId = ++u2f.reqCounter_; - u2f.callbackMap_[reqId] = callback; - var req = { - type: u2f.MessageTypes.U2F_GET_API_VERSION_REQUEST, - timeoutSeconds: (typeof opt_timeoutSeconds !== 'undefined' ? - opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC), - requestId: reqId - }; - port.postMessage(req); - }); -}; diff --git a/templates/base/footer.tmpl b/templates/base/footer.tmpl index 96c1908565ae3..3ad5358d4153d 100644 --- a/templates/base/footer.tmpl +++ b/templates/base/footer.tmpl @@ -65,7 +65,7 @@ {{end}} {{if .RequireU2F}} - + {{end}} {{if .RequireTribute}} diff --git a/templates/user/auth/u2f.tmpl b/templates/user/auth/u2f.tmpl index 8de79481ec295..fa5904fc38232 100644 --- a/templates/user/auth/u2f.tmpl +++ b/templates/user/auth/u2f.tmpl @@ -18,4 +18,5 @@ +{{template "user/auth/u2f_error" .}} {{template "base/footer" .}} diff --git a/templates/user/auth/u2f_error.tmpl b/templates/user/auth/u2f_error.tmpl new file mode 100644 index 0000000000000..e30b0647201bf --- /dev/null +++ b/templates/user/auth/u2f_error.tmpl @@ -0,0 +1,32 @@ + diff --git a/templates/user/settings/security_u2f.tmpl b/templates/user/settings/security_u2f.tmpl index 21bdfd4a0bc73..4703f9deb9a99 100644 --- a/templates/user/settings/security_u2f.tmpl +++ b/templates/user/settings/security_u2f.tmpl @@ -41,6 +41,8 @@ +{{template "user/auth/u2f_error" .}} +