authorio 0.8.0 → 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +52 -4
  3. data/app/assets/stylesheets/authorio/auth.css +55 -1
  4. data/app/controllers/authorio/auth_controller.rb +76 -91
  5. data/app/controllers/authorio/authorio_controller.rb +78 -0
  6. data/app/controllers/authorio/sessions_controller.rb +33 -0
  7. data/app/controllers/authorio/users_controller.rb +34 -0
  8. data/app/helpers/authorio/tag_helper.rb +17 -0
  9. data/app/jobs/authorio/application_job.rb +2 -0
  10. data/app/models/authorio/application_record.rb +2 -0
  11. data/app/models/authorio/request.rb +39 -1
  12. data/app/models/authorio/session.rb +43 -0
  13. data/app/models/authorio/token.rb +23 -1
  14. data/app/models/authorio/user.rb +14 -0
  15. data/app/views/authorio/auth/authorization_interface.html.erb +14 -35
  16. data/app/views/authorio/auth/issue_token.json.jbuilder +7 -0
  17. data/app/views/authorio/auth/send_profile.json.jbuilder +3 -0
  18. data/app/views/authorio/auth/verify_token.json.jbuilder +5 -0
  19. data/app/views/authorio/sessions/new.html.erb +14 -0
  20. data/app/views/authorio/users/_profile.json.jbuilder +10 -0
  21. data/app/views/authorio/users/edit.html.erb +25 -0
  22. data/app/views/authorio/users/show.html.erb +18 -0
  23. data/app/views/authorio/users/verify.html.erb +1 -0
  24. data/app/views/layouts/authorio/main.html.erb +38 -0
  25. data/app/views/shared/_login_form.html.erb +41 -0
  26. data/config/routes.rb +15 -5
  27. data/db/migrate/20210723161041_add_expiry_to_tokens.rb +5 -0
  28. data/db/migrate/20210726164625_create_authorio_sessions.rb +12 -0
  29. data/db/migrate/20210801184120_add_profile_to_users.rb +8 -0
  30. data/db/migrate/20210817010101_change_path_to_username_in_users.rb +7 -0
  31. data/lib/authorio/configuration.rb +14 -9
  32. data/lib/authorio/engine.rb +11 -8
  33. data/lib/authorio/exceptions.rb +20 -3
  34. data/lib/authorio/routes.rb +10 -7
  35. data/lib/authorio/version.rb +3 -1
  36. data/lib/authorio.rb +15 -21
  37. data/lib/generators/authorio/install/install_generator.rb +3 -3
  38. data/lib/generators/authorio/install/templates/authorio.rb +22 -8
  39. data/lib/tasks/authorio_tasks.rake +15 -14
  40. metadata +58 -30
  41. data/app/controllers/authorio/application_controller.rb +0 -4
  42. data/app/controllers/authorio/helpers.rb +0 -17
  43. data/app/helpers/authorio/application_helper.rb +0 -4
  44. data/app/helpers/authorio/test_helper.rb +0 -4
  45. data/app/views/layouts/authorio/application.html.erb +0 -15
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Authorio
4
+ class SessionsController < AuthorioController
5
+ # GET /session/new
6
+ def new
7
+ @session = Session.new(authorio_user: User.first)
8
+ end
9
+
10
+ # POST /session
11
+ def create
12
+ user = User.find_by_username! auth_user_params[:username]
13
+ raise Exceptions::InvalidPassword unless user.authenticate(auth_user_params[:password])
14
+
15
+ write_session_cookie(user) if auth_user_params[:remember_me]
16
+ # Even if we don't have a permanent remember-me session, we make a temporary session
17
+ session[:user_id] = user.id
18
+ redirect_to edit_user_path(user)
19
+ rescue Exceptions::InvalidPassword
20
+ redirect_back_with_error 'Incorrect password. Try again.'
21
+ end
22
+
23
+ # DELETE /session
24
+ def destroy
25
+ reset_session
26
+ if (cookie = cookies.encrypted[:user]) && (session = Session.find_by_cookie(cookie))
27
+ cookies.delete :user
28
+ session.destroy
29
+ end
30
+ redirect_to new_session_path
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Authorio
4
+ class UsersController < AuthorioController
5
+ before_action :authorized?, except: :verify
6
+
7
+ # GET /users/:id
8
+ def show
9
+ @user = User.find(params[:id])
10
+ end
11
+
12
+ # GET /users/:id/edit
13
+ def edit
14
+ @user = User.find(params[:id])
15
+ end
16
+
17
+ # PATCH /users/:id
18
+ def update
19
+ User.find(params[:id]).update(user_params)
20
+ flash[:info] = 'Profile Saved'
21
+ redirect_to edit_user_path
22
+ end
23
+
24
+ # This is only called by IndieAuth clients who wish to verify that a
25
+ # user profile URL we generated is in fact ours.
26
+ def verify; end
27
+
28
+ private
29
+
30
+ def user_params
31
+ params.require(:user).permit(:url, :photo, :full_name, :email)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Authorio
4
+ # These helpers are provided to the main application
5
+ module TagHelper
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ helper_method :indieauth_tag if respond_to?(:helper_method)
10
+ end
11
+
12
+ def indieauth_tag
13
+ tag(:link, rel: 'authorization_endpoint', href: URI.join(main_app.root_url, Authorio.authorization_path)) <<
14
+ tag(:link, rel: 'token_endpoint', href: URI.join(main_app.root_url, Authorio.token_path))
15
+ end
16
+ end
17
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Authorio
2
4
  class ApplicationJob < ActiveJob::Base
3
5
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Authorio
2
4
  class ApplicationRecord < ActiveRecord::Base
3
5
  self.abstract_class = true
@@ -1,7 +1,45 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Authorio
2
4
  class Request < ApplicationRecord
3
- belongs_to :authorio_user, class_name: "::Authorio::User"
5
+ belongs_to :authorio_user, class_name: '::Authorio::User'
4
6
 
5
7
  validates_presence_of :code, :redirect_uri, :client
8
+
9
+ before_validation :set_code, on: :create
10
+ before_create :sweep_requests
11
+
12
+ # The IndieAuth spec uses 'client_id' to specify the client in the address, as a URL (eg "https://example.com")
13
+ # But Rails uses '_id' to tag associations (foreign keys). So we save that as 'client' here, but map
14
+ # client_id as an alias since that is what the HTTP parameter will be
15
+ def client_id=(value)
16
+ self.client = value
17
+ end
18
+
19
+ def invalid?(params)
20
+ redirect_uri != params[:redirect_uri] ||
21
+ client != params[:client_id] ||
22
+ created_at < Time.now - 10.minutes
23
+ end
24
+
25
+ def self.user_scope_description(scope)
26
+ USER_SCOPE_DESCRIPTION[scope.to_sym] || scope
27
+ end
28
+
29
+ private
30
+
31
+ def set_code
32
+ self.code = SecureRandom.hex(20)
33
+ end
34
+
35
+ def sweep_requests
36
+ Request.where(client: client, authorio_user: authorio_user).destroy_all
37
+ end
38
+
39
+ USER_SCOPE_DESCRIPTION = {
40
+ profile: 'View basic profile information',
41
+ email: 'View your email address',
42
+ offline_access: 'Keep you logged in permanently (until revoked)'
43
+ }.freeze
6
44
  end
7
45
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Authorio
4
+ class Session < ApplicationRecord
5
+ # Implement a session cookie store based on best security practices
6
+ # See: https://paragonie.com/blog/2015/04/secure-authentication-php-with-long-term-persistence
7
+ belongs_to :authorio_user, class_name: '::Authorio::User'
8
+
9
+ # 1. Protect against having database stolen by only storing token hashes
10
+ attribute :token # This will not be persisted in the DB
11
+ has_secure_token
12
+
13
+ before_create do
14
+ self.expires_at = Time.now + Authorio.configuration.token_expiration
15
+ self.selector = SecureRandom.hex(12)
16
+ self.hashed_token = Digest::SHA256.hexdigest token
17
+ end
18
+
19
+ # 2. To guard against timing attacks, we lookup tokens based on a separate selector attribute
20
+ # and compare them using a secure time-constant comparison method
21
+ def self.find_by_cookie(cookie)
22
+ selector, _token = cookie.split(':')
23
+ session = find_by selector: selector
24
+ raise Authorio::Exceptions::SessionReplayAttack.new, session unless session.matches_cookie?(cookie)
25
+
26
+ session
27
+ end
28
+
29
+ def matches_cookie?(cookie)
30
+ _selector, token = cookie.split(':')
31
+ cookie_hashed_token = Digest::SHA256.hexdigest token
32
+ !expired? && ActiveSupport::SecurityUtils.secure_compare(cookie_hashed_token, hashed_token)
33
+ end
34
+
35
+ def expired?
36
+ expires_at < Time.now
37
+ end
38
+
39
+ def as_cookie
40
+ "#{selector}:#{token}"
41
+ end
42
+ end
43
+ end
@@ -1,8 +1,30 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Authorio
2
4
  class Token < ApplicationRecord
3
- belongs_to :authorio_user, class_name: "::Authorio::User"
5
+ belongs_to :authorio_user, class_name: '::Authorio::User'
4
6
  has_secure_token :auth_token
5
7
 
6
8
  validates_presence_of :scope, :client
9
+
10
+ before_create do
11
+ self.expires_at = Time.now + Authorio.configuration.token_expiration
12
+ end
13
+
14
+ # The token endpoint can get hit by bots, so short-circut the find if they
15
+ # don't send a bearer token
16
+ def self.find_by_auth_token(token)
17
+ token and find_by auth_token: token
18
+ end
19
+
20
+ def expired?
21
+ expires_at < Time.now
22
+ end
23
+
24
+ def self.create_from_request(req)
25
+ raise Exceptions::InvalidGrant, 'missing scope' if req.scope.blank?
26
+
27
+ Token.create(authorio_user: req.authorio_user, scope: req.scope, client: req.client)
28
+ end
7
29
  end
8
30
  end
@@ -1,5 +1,19 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Authorio
2
4
  class User < ApplicationRecord
3
5
  has_secure_password
6
+
7
+ class << self
8
+ def find_by_url!(url)
9
+ find_by_username!(URI(url).path)
10
+ end
11
+
12
+ def find_by_username!(name)
13
+ return first unless Authorio.configuration.multiuser
14
+
15
+ find_by(username: name) or raise Exceptions::UserNotFound
16
+ end
17
+ end
4
18
  end
5
19
  end
@@ -1,37 +1,16 @@
1
- <!DOCTYPE html>
2
- <html lang="en-GB">
3
- <head>
4
- <meta charset="utf-8">
5
- <title>Authorio Login</title>
6
- <meta name="viewport" content="width=device-width, initial-scale=1">
7
- <link rel="stylesheet"
8
- href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css"
9
- integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u"
10
- crossorigin="anonymous">
11
- <%= stylesheet_link_tag "authorio/auth" %>
12
- </head>
13
- <body>
14
- <div class="container authorio-auth">
15
- <div class="row">
16
- <div class="col-md-4">
17
- </div>
18
- <div class="col-md-4 auth-panel">
19
- <h3>Authorio</h3>
20
- <div class="client-row">
21
- Authenticating with <span class="client"><%= params[:client_id] %>
22
- </div>
23
- <%= form_with(url: "authorize_user", method: :post) do |form| %>
24
- <%= form.label(:url, "User URL") %>
25
- <%= form.text_field(:url, value: @user_url, readonly: true) %>
26
- <%= form.label(:password, "Password") %>
27
- <%= form.password_field(:password) %>
28
- <%= form.hidden_field(:client, value: params[:client_id]) %>
29
- <%= form.submit("Sign in", class: 'btn btn-success') %>
30
- <% end %>
31
- </div>
32
- <div class="col-md-4">
33
- </div>
1
+ <% content_for :title, "Authorio Login" %>
2
+
3
+ <div class="container authorio-auth">
4
+ <div class="row">
5
+ <div class="col-md-4"></div>
6
+ <div class="col-md-4 auth-panel">
7
+ <h3>Authorio</h3>
8
+ <div class="client-row">
9
+ <span class="client"><%= params[:client_id] %></span> wants to authenticate
10
+ <% if params[:scope] %>and also<% end %>
34
11
  </div>
12
+ <%= render 'shared/login_form', target: authorize_user_path, cancel: true %>
35
13
  </div>
36
- </body>
37
- </html>
14
+ <div class="col-md-4"></div>
15
+ </div>
16
+ </div>
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ json.access_token @token.auth_token
4
+ json.expires_in Authorio.configuration.token_expiration
5
+ json.token_type 'Bearer'
6
+ json.scope @token.scope
7
+ json.partial! 'authorio/users/profile', request: @request
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ json.partial! 'authorio/users/profile', request: @request
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ json.me url_for(@token.authorio_user)
4
+ json.client_id @token.client
5
+ json.scope @token.scope
@@ -0,0 +1,14 @@
1
+ <%= stylesheet_link_tag "authorio/auth" %>
2
+ <% content_for :title, "Authorio Local Login" %>
3
+
4
+ <div class="container authorio-auth">
5
+ <div class="row">
6
+ <div class="col-md-4"></div>
7
+ <div class="col-md-4 auth-panel">
8
+ <h3>Authorio</h3>
9
+ <div class="client-row">Local Login</div>
10
+ <%= render 'shared/login_form', target: session_path(@session), cancel: false %>
11
+ </div>
12
+ <div class="col-md-4"></div>
13
+ </div>
14
+ </div>
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ json.me profile_url(request.authorio_user)
4
+ if request.scope&.include? 'profile'
5
+ json.profile do
6
+ json.name(request.authorio_user.full_name)
7
+ json.call(request.authorio_user, :url, :photo)
8
+ json.email(request.authorio_user.email) if request.scope.include?('email')
9
+ end
10
+ end
@@ -0,0 +1,25 @@
1
+ <%= stylesheet_link_tag "authorio/auth" %>
2
+ <% content_for :title, "Account Settings" %>
3
+
4
+ <div class="container authorio-auth">
5
+ <div class="row">
6
+ <div class="col-md-4"></div>
7
+ <div class="col-md-4 auth-panel">
8
+ <h3>Account Settings</h3>
9
+ <%= form_with model: @user do |form| %>
10
+ <%= form.label(:full_name, "Full Name") %>
11
+ <%= form.text_field(:full_name) %>
12
+ <%= form.label(:url, "URL") %>
13
+ <%= form.text_field(:url) %>
14
+ <%= form.label(:photo, "Photo URL") %>
15
+ <%= form.text_field(:photo) %>
16
+ <%= form.label(:email, "Email") %>
17
+ <%= form.text_field(:email) %>
18
+ <div class='auth-btn-row'>
19
+ <%= form.submit("Save Changes", class: 'btn btn-success auth-btn') %>
20
+ </div>
21
+ <% end -%>
22
+ </div>
23
+ <div class="col-md-4"></div>
24
+ </div>
25
+ </div>
@@ -0,0 +1,18 @@
1
+ <%= stylesheet_link_tag "authorio/auth" %>
2
+ <% content_for :title, "User Account" %>
3
+
4
+ <%= indieauth_tag %>
5
+
6
+ <div class="container authorio-auth">
7
+ <div class="row">
8
+ <div class="col-md-4"></div>
9
+ <div class="col-md-4 auth-panel">
10
+ <h3>IndieAuth Account</h3>
11
+ Full Name <%= @user.full_name %>
12
+ URL <%= @user.url %>
13
+ Photo <%= image_tag @user.photo %>
14
+ Email <%= @user.email %>
15
+ </div>
16
+ <div class="col-md-4"></div>
17
+ </div>
18
+ </div>
@@ -0,0 +1 @@
1
+ <%= indieauth_tag %>
@@ -0,0 +1,38 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title><%= yield(:title) %></title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+ <meta charset="utf-8">
8
+ <meta name="viewport" content="width=device-width, initial-scale=1">
9
+ <link rel="stylesheet"
10
+ href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css"
11
+ integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u"
12
+ crossorigin="anonymous">
13
+
14
+ <%= stylesheet_link_tag "authorio/application", media: "all" %>
15
+ <%= stylesheet_link_tag "authorio/auth" %>
16
+ </head>
17
+ <body>
18
+
19
+ <% if logged_in? %>
20
+ <div class="topbar">
21
+ <ul>
22
+ <li>Authorio</li>
23
+ <li><a href="<%= edit_user_path(current_user) -%>">Account Settings</a></li>
24
+ <li><a href="<%= logout_path(method: :delete) -%>">Log Out</a></li>
25
+ </ul>
26
+ </div>
27
+ <% end -%>
28
+
29
+ <% flash.each do |key, value| %>
30
+ <div class="alert alert-warning">
31
+ <p><%= value %></p>
32
+ </div>
33
+ <% end %>
34
+
35
+ <%= yield %>
36
+
37
+ </body>
38
+ </html>
@@ -0,0 +1,41 @@
1
+ <%= form_with(url: target, method: :post) do |form| %>
2
+ <% if params[:scope] %>
3
+ <%= fields_for :scope do |req_scope| %>
4
+ <div class="scopes">
5
+ <ul class="scope">
6
+ <% for scope in params[:scope].split %>
7
+ <li>
8
+ <%= label_tag(:scope, class: 'scope-label') do %>
9
+ <%= req_scope.check_box(:scope, {multiple: true, checked: true}, scope, nil) %>
10
+ <%= user_scope_description scope %>
11
+ <% end -%>
12
+ </li>
13
+ <%- end %>
14
+ </ul>
15
+ </div>
16
+ <% end %>
17
+ <% end -%>
18
+ <%= fields_for :user do |user_scope| %>
19
+ <%= user_scope.hidden_field :dummy, value: 42 %>
20
+ <% if Authorio.configuration.multiuser %>
21
+ <%= user_scope.label(:username, "Username") %>
22
+ <%= user_scope.text_field(:username) %>
23
+ <% end -%>
24
+ <% unless logged_in? %>
25
+ <%= user_scope.label(:password, "Password") %>
26
+ <%= user_scope.password_field(:password, autofocus: true) %>
27
+ <% if rememberable? %>
28
+ <%= label_tag(:remember_me, class: 'remember') do %>
29
+ <%= user_scope.check_box :remember_me %>
30
+ <span class='r-m'>Remember me for <%= distance_of_time_in_words Authorio.configuration.local_session_lifetime -%></span>
31
+ <% end %>
32
+ <% end %>
33
+ <% end %>
34
+ <% end -%>
35
+ <div class='auth-btn-row'>
36
+ <% if cancel %>
37
+ <%= form.submit("Cancel", class: 'btn btn-default auth-btn') %>
38
+ <% end %>
39
+ <%= form.submit("Sign in", class: 'btn btn-success auth-btn') %>
40
+ </div>
41
+ <% end %>