diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index ca3d5387..24fd6f3c 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -56,6 +56,8 @@ jobs: TEST_DB_USERNAME: postgres TEST_DB_PASSWORD: postgres TEST_DB_NAME: postgres + discourse_sso_secret: "secret" + discourse_endpoint: "https://example.com" PG_PORT: ${{ job.services.postgres.ports['5432'] }} #- name: Deploy to server via SSH diff --git a/Gemfile b/Gemfile index b003fde5..431d6c0a 100644 --- a/Gemfile +++ b/Gemfile @@ -5,6 +5,11 @@ gem 'rails', '6.1.7.3' gem 'sidekiq', '~> 6' gem 'doorkeeper', '~> 5' +gem 'jquery-rails' +gem 'sass-rails' +gem 'turbolinks' +gem 'uglifier' + # To resize active storage images: # Revise if this is needed after Rails 6.0 gem 'image_processing' @@ -91,6 +96,7 @@ group :development, :test do gem 'brakeman', github: 'presidentbeef/brakeman', require: false gem 'byebug' gem 'cane' + gem "capybara" gem 'factory_bot_rails' gem 'faker' gem 'guard-rspec' diff --git a/Gemfile.lock b/Gemfile.lock index 09d294a4..04ee3a7d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -119,6 +119,15 @@ GEM c_geohash (1.1.2) cane (3.0.0) parallel + capybara (3.40.0) + addressable + matrix + mini_mime (>= 0.1.3) + nokogiri (~> 1.11) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + regexp_parser (>= 1.5, < 3.0) + xpath (~> 3.2) choice (0.2.0) coderay (1.1.3) concurrent-ruby (1.2.2) @@ -151,6 +160,7 @@ GEM tzinfo eventmachine (1.2.7) excon (0.99.0) + execjs (2.9.1) factory_bot (6.2.1) activesupport (>= 5.0.0) factory_bot_rails (6.2.0) @@ -221,6 +231,10 @@ GEM actionview (>= 5.0.0) activesupport (>= 5.0.0) jmespath (1.6.2) + jquery-rails (4.6.0) + rails-dom-testing (>= 1, < 3) + railties (>= 4.2.0) + thor (>= 0.14, < 2.0) json (2.6.3) jwt (2.7.0) kaminari (1.2.2) @@ -252,6 +266,7 @@ GEM json (>= 1.7.7) rest-client (>= 1.6.7) marcel (1.0.2) + matrix (0.4.2) method_source (1.0.0) mime-types (3.4.1) mime-types-data (~> 3.2015) @@ -445,6 +460,16 @@ GEM rufus-scheduler (3.8.2) fugit (~> 1.1, >= 1.1.6) safe_yaml (1.0.5) + sass-rails (6.0.0) + sassc-rails (~> 2.1, >= 2.1.1) + sassc (2.4.0) + ffi (~> 1.9) + sassc-rails (2.1.2) + railties (>= 4.0.0) + sassc (>= 2.0) + sprockets (> 3.0) + sprockets-rails + tilt sentry-rails (5.9.0) railties (>= 5.0) sentry-ruby (~> 5.9.0) @@ -505,8 +530,13 @@ GEM timeout (0.3.2) treetop (1.6.12) polyglot (~> 0.3) + turbolinks (5.2.1) + turbolinks-source (~> 5.2) + turbolinks-source (5.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) + uglifier (4.2.1) + execjs (>= 0.3.0, < 3) unaccent (0.4.0) unf (0.1.4) unf_ext @@ -526,6 +556,8 @@ GEM workflow-activerecord (6.0.0) activerecord (>= 6.0) workflow (~> 3.0) + xpath (3.2.0) + nokogiri (~> 1.8) yard (0.9.34) zeitwerk (2.6.8) zonebie (0.6.1) @@ -546,6 +578,7 @@ DEPENDENCIES byebug c_geohash cane + capybara countries dalli date_validator @@ -561,6 +594,7 @@ DEPENDENCIES guard-rspec image_processing jbuilder + jquery-rails kaminari listen mailgun_rails @@ -594,6 +628,7 @@ DEPENDENCIES rspec-rails rubocop rufus-scheduler + sass-rails sentry-rails sentry-ruby sentry-sidekiq @@ -608,6 +643,8 @@ DEPENDENCIES stamp timecop treetop + turbolinks + uglifier vcr versionist! webmock @@ -620,4 +657,4 @@ RUBY VERSION ruby 3.0.6p216 BUNDLED WITH - 2.5.21 + 2.5.22 diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js new file mode 100644 index 00000000..b16e53d6 --- /dev/null +++ b/app/assets/config/manifest.js @@ -0,0 +1,3 @@ +//= link_tree ../images +//= link_directory ../javascripts .js +//= link_directory ../stylesheets .css diff --git a/app/assets/images/sck.png b/app/assets/images/sck.png new file mode 100644 index 00000000..c72f519d Binary files /dev/null and b/app/assets/images/sck.png differ diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js new file mode 100644 index 00000000..e07c5a83 --- /dev/null +++ b/app/assets/javascripts/application.js @@ -0,0 +1,16 @@ +// This is a manifest file that'll be compiled into application.js, which will include all the files +// listed below. +// +// Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, +// or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path. +// +// It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the +// compiled file. +// +// Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details +// about supported directives. +// +//= require jquery +//= require jquery_ujs +//= require turbolinks +//= require_tree . diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css new file mode 100644 index 00000000..f9cd5b34 --- /dev/null +++ b/app/assets/stylesheets/application.css @@ -0,0 +1,15 @@ +/* + * This is a manifest file that'll be compiled into application.css, which will include all the files + * listed below. + * + * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, + * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. + * + * You're free to add application-wide styles to this file and they'll appear at the bottom of the + * compiled file so the styles you add here take precedence over styles defined in any styles + * defined in the other CSS/SCSS files in this directory. It is generally better to create a new + * file per style scope. + * + *= require_tree . + *= require_self + */ diff --git a/app/assets/stylesheets/sessions.scss b/app/assets/stylesheets/sessions.scss new file mode 100644 index 00000000..a050bd3e --- /dev/null +++ b/app/assets/stylesheets/sessions.scss @@ -0,0 +1,63 @@ +// Place all the styles related to the sessions controller here. +// They will automatically be included in application.css. +// You can use Sass (SCSS) here: http://sass-lang.com/ +$blue: #0C2EFB; +$black: #212121; +$grey: #E3E3E3; +$red: #FA5161; + +@import url('https://fonts.googleapis.com/css?family=Roboto+Condensed:400,300,300italic,400italic,700,700italic|Roboto:400,700,700italic,400italic'); +@import url('https://fonts.googleapis.com/css?family=Kanit:400,500,600,700,900'); + +*{ + font-family:'Kanit'; +} +body{ + background-color: #e3e3e3; +} +h1{ + font-weight:700 !important; +} +.button { + border: none; + color: white; + padding: 15px 32px; + text-align: center; + text-decoration: none; + display: inline-block; + font-size: 18px; +} +.bg-blue{ + background-color: $blue !important; +} + +.button:hover{ + cursor: pointer; + color: white; + text-decoration:underline; +} + +.bg-red{ + background-color: $red; +} +.color-red{ + color: $red; +} + +input[type=text],input[type=password] { + padding:7px; + border:none; + border-bottom:2px solid #BEBEBE; +} + +#flash_notice{ + padding:20px; + color: white; + background-color: $blue; +} + +#flash_alert{ + padding:20px; + color: white; + background-color: $red; +} diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 0d5ef819..6eae9fce 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,5 +1,3 @@ # this file is required for errbit notifier -class ApplicationController < ActionController::API - include ActionController::ImplicitRender - include ActionController::Helpers +class ApplicationController < ActionController::Base end diff --git a/app/controllers/discourse_controller.rb b/app/controllers/discourse_controller.rb new file mode 100644 index 00000000..80ea755d --- /dev/null +++ b/app/controllers/discourse_controller.rb @@ -0,0 +1,32 @@ +class DiscourseController < ApplicationController + include SharedControllerMethods + + DISCOURSE_SSO_SECRET = ENV.fetch("discourse_sso_secret") + DISCOURSE_ENDPOINT = ENV.fetch("discourse_endpoint") + def sso + if !current_user + session[:discourse_url] = request.url + redirect_to new_ui_session_path(goto: request.path), notice: 'Please Log In before using SSO' + return + end + secret = DISCOURSE_SSO_SECRET + sso = SingleSignOn.parse(request.query_string, secret) + sso.email = current_user.email # from devise + #sso.name = current_user.full_name # this is a custom method on the User class + sso.username = current_user.email # from devise + #sso.username = current_user.username + sso.external_id = current_user.id # from devise + sso.sso_secret = secret + + redirect_to sso.to_url("#{DISCOURSE_ENDPOINT}session/sso_login") + rescue => e + Rails.logger.error(e.message) + Rails.logger.error(e.backtrace) + #flash[:error] = 'SSO error' + render inline: "Error, check logs" + + #redirect_to "/" + #redirect_to root + end + +end diff --git a/app/controllers/shared_controller_methods.rb b/app/controllers/shared_controller_methods.rb new file mode 100644 index 00000000..1aa48725 --- /dev/null +++ b/app/controllers/shared_controller_methods.rb @@ -0,0 +1,29 @@ +module SharedControllerMethods + + include Pundit::Authorization + + def self.included(klass) + klass.helper_method :current_user + end + + def current_user(fail_unauthorized=true) + if @current_user.nil? + if session[:user_id] + @current_user = User.find(session[:user_id]) + elsif doorkeeper_token + # return render text: 'abc' + @current_user = User.find(doorkeeper_token.resource_owner_id) + elsif ActionController::HttpAuthentication::Basic.has_basic_credentials?(request) # username and password + authenticate_with_http_basic do |username, password| + if user = User.find_by(username: username) and user.authenticate_with_legacy_support(password) + @current_user = user + elsif fail_unauthorized + self.headers["WWW-Authenticate"] = %(Basic realm="Application", Token realm="Application") + raise Smartcitizen::Unauthorized.new "Invalid Username/Password Combination" + end + end + end + end + @current_user + end +end diff --git a/app/controllers/ui/application_controller.rb b/app/controllers/ui/application_controller.rb new file mode 100644 index 00000000..0ac4236a --- /dev/null +++ b/app/controllers/ui/application_controller.rb @@ -0,0 +1,6 @@ +module Ui + class ApplicationController < ActionController::Base + layout "application" + include SharedControllerMethods + end +end diff --git a/app/controllers/ui/sessions_controller.rb b/app/controllers/ui/sessions_controller.rb new file mode 100644 index 00000000..56a0095a --- /dev/null +++ b/app/controllers/ui/sessions_controller.rb @@ -0,0 +1,80 @@ +module Ui + class SessionsController < ApplicationController + include SharedControllerMethods + require 'uri' + require 'net/http' + require 'net/https' + + def new + redirect_to ui_users_url if current_user + end + + def index + redirect_to new_ui_session_path + end + + def create + if params[:send_password_email] + reset_password_email + redirect_to new_ui_session_path + return + end + + user = User.where("lower(email) = lower(?) OR lower(username) = lower(?)", + params[:username_or_email], params[:username_or_email]).first + if user && user.authenticate_with_legacy_support(params[:password]) + session[:user_id] = user.id + + if params[:goto].include? 'discourse' + redirect_to session[:discourse_url] + else + redirect_to (session[:user_return_to] || ui_users_path), notice: "You have been successfully logged in!" + end + else + flash.now.alert = "Email or password is invalid" + render "new" + end + end + + def reset_password_email + user = User.where("lower(email) = lower(?) OR lower(username) = lower(?)", + params[:username_or_email], params[:username_or_email]).first + + if user + authorize user, :request_password_reset? + user.send_password_reset + end + flash[:notice] = 'Please check your email to reset the password.' + end + + def password_reset_landing + @token = params[:token] + end + + def change_password + @token = params.require(:token) + + if params.require(:password) != params.require(:password_confirmation) + flash[:alert] ="Your password doesn't match the confirmation" + render "password_reset_landing" + return + end + + @user = User.find_by(password_reset_token: @token) + if @user + authorize @user, :update_password? + @user.update({ password: params.require(:password), password_reset_token: nil }) + flash[:notice] = "Changed password for: #{@user.username}" + redirect_to new_ui_session_path + else + flash[:alert] = 'Your reset code might be too old or have been used before.' + render "password_reset_landing" + end + end + + def destroy + session[:user_id] = nil + redirect_to login_url, notice: "Logged out!" + end + end +end diff --git a/app/controllers/ui/users_controller.rb b/app/controllers/ui/users_controller.rb new file mode 100644 index 00000000..3ff935e2 --- /dev/null +++ b/app/controllers/ui/users_controller.rb @@ -0,0 +1,7 @@ +module Ui + class UsersController < ApplicationController + include SharedControllerMethods + def index + end + end +end diff --git a/app/controllers/v0/application_controller.rb b/app/controllers/v0/application_controller.rb index 4498e915..8be8590b 100644 --- a/app/controllers/v0/application_controller.rb +++ b/app/controllers/v0/application_controller.rb @@ -8,13 +8,14 @@ class ApplicationController < ActionController::API include ActionController::ImplicitRender include ActionController::Caching - include Pundit::Authorization include PrettyJSON include ErrorHandlers helper ::UserHelper include ::UserHelper + include SharedControllerMethods + respond_to :json before_action :prepend_view_paths @@ -66,26 +67,6 @@ def set_rate_limit_whitelist end end - def current_user(fail_unauthorized=true) - if @current_user.nil? - if doorkeeper_token - # return render text: 'abc' - @current_user = User.find(doorkeeper_token.resource_owner_id) - elsif ActionController::HttpAuthentication::Basic.has_basic_credentials?(request) # username and password - authenticate_with_http_basic do |username, password| - if user = User.find_by(username: username) and user.authenticate_with_legacy_support(password) - @current_user = user - elsif fail_unauthorized - self.headers["WWW-Authenticate"] = %(Basic realm="Application", Token realm="Application") - raise Smartcitizen::Unauthorized.new "Invalid Username/Password Combination" - end - end - end - end - @current_user - end - helper_method :current_user - def check_if_authorized! if current_user.nil? if params[:access_token] diff --git a/app/controllers/v0/password_resets_controller.rb b/app/controllers/v0/password_resets_controller.rb index e260b054..7bdd09dd 100644 --- a/app/controllers/v0/password_resets_controller.rb +++ b/app/controllers/v0/password_resets_controller.rb @@ -28,7 +28,6 @@ def create # 2/3 - The associated user object is returned, indicating a valid token def show @user = User.find_by!(password_reset_token: params[:id]) - @current_user = @user authorize @user, :update_password? render 'users/show', status: :ok end @@ -36,7 +35,6 @@ def show # 3/3 - The password reset is submitted and committed to the database def update @user = User.find_by!(password_reset_token: params[:id]) - @current_user = @user authorize @user, :update_password? if @user.update({ password: params.require(:password), password_reset_token: nil }) render 'users/show', status: :ok diff --git a/app/controllers/v0/sessions_controller.rb b/app/controllers/v0/sessions_controller.rb index f47de389..bc7d2ea9 100644 --- a/app/controllers/v0/sessions_controller.rb +++ b/app/controllers/v0/sessions_controller.rb @@ -8,8 +8,10 @@ def create authorize user, :show? if user && user.authenticate_with_legacy_support(params[:password]) # $analytics.track("login:successful", user.id) + session[:user_id] = user.id render json: { access_token: user.access_token!.token }, status: :ok else + session[:user_id] = nil raise Smartcitizen::UnprocessableEntity.new({ message: {password: 'is incorrect'}, # to be removed password: 'is incorrect' # to replace the above diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 92b64eb9..63ba866c 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -21,7 +21,6 @@ def request_password_reset? end def update_password? - update? + create? end - end diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb new file mode 100644 index 00000000..0a2bdfca --- /dev/null +++ b/app/views/layouts/application.html.erb @@ -0,0 +1,25 @@ + + + + SmartCitizen Authentication helper + + + <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track' => true %> + <%= javascript_include_tag 'application', 'data-turbolinks-track' => true %> + <%= csrf_meta_tags %> + + + + <% flash.each do |name, msg| %> + <%= content_tag :div, msg, id: "flash_#{name}" %> + <% end %> + +
+
+ SCK logo + <%= yield %> +
+
+ <%= VERSION_FILE %> + + diff --git a/app/views/ui/sessions/new.html.erb b/app/views/ui/sessions/new.html.erb new file mode 100644 index 00000000..41521e95 --- /dev/null +++ b/app/views/ui/sessions/new.html.erb @@ -0,0 +1,31 @@ +

WELCOME!

+ +<%= form_tag ui_sessions_path do %> +
+ +
+
+ <%= label_tag :username_or_email, "Username or Email" %>
+ <%= text_field_tag :username_or_email, params[:username_or_email], autofocus: params[:username_or_email].blank? %> +
+
+ <%= label_tag :password %>
+ <%= password_field_tag :password %> +
+
+ <%= hidden_field_tag :goto, params[:goto] %> + <%= submit_tag "SIGN IN TO YOUR ACCOUNT", class: 'button bg-blue', style: 'width:100%' %> +
+ +
+
Don't have an account?
+ SIGN UP +
+ +
+
+
Forgot your password?
+
+ <%= submit_tag "RESET PASSWORD", name: 'send_password_email', class: 'button bg-red' %> +
+<% end %> diff --git a/app/views/ui/sessions/password_reset_landing.html.erb b/app/views/ui/sessions/password_reset_landing.html.erb new file mode 100644 index 00000000..ae746bc0 --- /dev/null +++ b/app/views/ui/sessions/password_reset_landing.html.erb @@ -0,0 +1,22 @@ +

Change password

+ +<%= form_tag ui_change_password_path do %> +
+ + +
+ <%= hidden_field_tag :token, @token %> +
+
+ <%= label_tag :password %>
+ <%= password_field_tag :password %> +
+
+ <%= label_tag :password_confirmation, "Confirm new password" %>
+ <%= password_field_tag :password_confirmation %> +
+
+ <%= submit_tag "Change my password", name: 'change_password', class: 'button bg-red', style: 'width:100%' %> +
+
+<% end %> diff --git a/app/views/ui/users/index.html.erb b/app/views/ui/users/index.html.erb new file mode 100644 index 00000000..fceae1b9 --- /dev/null +++ b/app/views/ui/users/index.html.erb @@ -0,0 +1,25 @@ +

User info

+ +
+
+ + <%= link_to "Smartcitizen Website", "https://www.smartcitizen.me/" %>
+ <%= link_to "Smartcitizen Forum", "https://forum.smartcitizen.me/" %>
+ <%= link_to "Smartcitizen API", "https://api.smartcitizen.me/" %>
+ <%= link_to "Smartcitizen Developer documentation", "https://developer.smartcitizen.me/" %>
+ <%= link_to "See JSON user data from example.smartcitizen.me", "http://example.smartcitizen.me" %> + +
+ + <% if current_user %> +

Logged in as <%= current_user.email %>.

+

Your access token: +
<%= current_user.access_token.token %>

+

<%= link_to "Log Out", logout_path, class: 'button bg-red' %>

+ <% else %> +

Not logged in!

+ GO BACK + <% end %> + +
+
diff --git a/compose.override.local.yml b/compose.override.local.yml index 696dd628..269098cb 100644 --- a/compose.override.local.yml +++ b/compose.override.local.yml @@ -1,6 +1,4 @@ services: - auth: - restart: "no" app: build: args: !reset [] diff --git a/compose.yml b/compose.yml index 0b0428c2..2fb4790a 100644 --- a/compose.yml +++ b/compose.yml @@ -1,7 +1,6 @@ include: - compose/db.yml - compose/redis.yml - - compose/auth.yml - compose/app.yml - compose/sidekiq.yml - compose/mqtt-task.yml diff --git a/compose/app.yml b/compose/app.yml index 0d202996..d729e4d9 100644 --- a/compose/app.yml +++ b/compose/app.yml @@ -12,7 +12,6 @@ services: depends_on: # We disable some containers in production - db - - auth - redis - sidekiq - mqtt-task-main-1 diff --git a/compose/auth.yml b/compose/auth.yml deleted file mode 100644 index 39267ae6..00000000 --- a/compose/auth.yml +++ /dev/null @@ -1,9 +0,0 @@ -services: - auth: - env_file: ../.env - image: smartcitizen/auth - ports: - - "3001:3000" - restart: always - environment: - db_pool_size: 30 diff --git a/compose/web.yml b/compose/web.yml index 550f1960..a3857300 100644 --- a/compose/web.yml +++ b/compose/web.yml @@ -11,6 +11,7 @@ services: - 443:443 - 443:443/udp volumes: + - ../public:/app/public - ../scripts/nginx-conf/api.smartcitizen.me.conf:/etc/nginx/conf.d/api.smartcitizen.me.conf - ../scripts/nginx.conf:/etc/nginx/nginx.conf - ../scripts/certs:/etc/ssl:ro diff --git a/config/application.rb b/config/application.rb index caab0290..046f3c21 100644 --- a/config/application.rb +++ b/config/application.rb @@ -9,7 +9,7 @@ require "action_controller/railtie" require "action_mailer/railtie" require "action_view/railtie" -#require "sprockets/railtie" +require "sprockets/railtie" # require "rails/test_unit/railtie" # require 'actionpack/action_caching' @@ -71,7 +71,7 @@ class Application < Rails::Application request_specs: false end - config.api_only = true + config.api_only = false config.active_storage.service_urls_expire_in = 30.minutes config.middleware.use Rack::Deflater end diff --git a/config/environments/development.rb b/config/environments/development.rb index a23c9e15..4aeb069d 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -27,6 +27,10 @@ config.cache_store = :null_store end + config.assets.debug = true + config.assets.digest = true + config.assets.raise_ruuntime_errors = true + # Store uploaded files on the local file system (see config/storage.yml for options). #config.active_storage.service = :amazon config.active_storage.service = :local diff --git a/config/environments/production.rb b/config/environments/production.rb index 6071dd7b..f7bc7f05 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -29,6 +29,10 @@ # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX + config.assets.js_compressor = :uglifier + config.assets.compile = false + config.assets.digest = true + # Store uploaded files on the local file system (see config/storage.yml for options). config.active_storage.service = :amazon diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb new file mode 100644 index 00000000..01ef3e66 --- /dev/null +++ b/config/initializers/assets.rb @@ -0,0 +1,11 @@ +# Be sure to restart your server when you modify this file. + +# Version of your assets, change this if you want to expire all your assets. +Rails.application.config.assets.version = '1.0' + +# Add additional assets to the asset load path +# Rails.application.config.assets.paths << Emoji.images_path + +# Precompile additional assets. +# application.js, application.css, and all non-JS/CSS in app/assets folder are already added. +# Rails.application.config.assets.precompile += %w( search.js ) diff --git a/config/routes.rb b/config/routes.rb index d1664c88..e84ea258 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -7,6 +7,20 @@ end mount Sidekiq::Web, at: "/sidekiq" + get "discourse/sso" => 'discourse#sso' + + get "login", to: redirect("/ui/sessions/new") + get "logout", to: redirect("/ui/sessions/destroy") + get "password_reset/:token", to: redirect("/ui/password_reset/%{token}") + + namespace "ui" do + resources :users, as: "users" + get "sessions/destroy", to: "sessions#destroy" + resources :sessions, as: "sessions" + post 'change_password', to: 'sessions#change_password', as: 'change_password' + get 'password_reset/:token', to: 'sessions#password_reset_landing', as: 'password_reset' + end + api_version(module: "V0", path: {value: "v0"}, header: {name: "Accept", value: "application/vnd.smartcitizen; version=0"}, default: true, defaults: { format: :json }) do # devices resources :devices do diff --git a/public/favicon.ico b/public/favicon.ico index e69de29b..f36a475b 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/scripts/nginx-conf/api.smartcitizen.me.conf b/scripts/nginx-conf/api.smartcitizen.me.conf index db548b7e..158d14f9 100644 --- a/scripts/nginx-conf/api.smartcitizen.me.conf +++ b/scripts/nginx-conf/api.smartcitizen.me.conf @@ -18,7 +18,7 @@ server { } server { - server_name id.smartcitizen.me id2.smartcitizen.me staging-id.smartcitizen.me; + server_name id.smartcitizen.me; listen 80; listen [::]:80; @@ -28,42 +28,25 @@ server { ssl_certificate /etc/ssl/star_smartcitizen_me.pem; ssl_certificate_key /etc/ssl/star_smartcitizen_me.key; - try_files $uri/index.html $uri @app; - - location @app { - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Ssl on; # Optional - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $http_host; - proxy_redirect off; - proxy_pass http://auth:3000; + rewrite ^/users/(.*)$ https://api.smartcitizen.com/ui/users/$1 permanent; + rewrite ^/sessions/(.*)$ https://api.smartcitizen.com/ui/sessions/$1 permanent; + rewrite ^/(.*)$ https://api.smartcitizen.com/$1 permanent; +} - # Reverse proxy cache - #proxy_cache smartcitizen; - proxy_cache_lock on; - proxy_cache_use_stale updating; +server { + server_name staging-id.smartcitizen.me; - # Add CORS - if ($request_method = 'OPTIONS') { - add_header 'Access-Control-Allow-Origin' '*'; - add_header 'Access-Control-Allow-Credentials' 'true'; - add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, PATCH, DELETE, OPTIONS'; - add_header 'Access-Control-Allow-Headers' 'Authorization,OnboardingSession,DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type'; + listen 80; + listen [::]:80; - # Tell client that this pre-flight info is valid for 30 days - add_header 'Access-Control-Max-Age' 2592000; - add_header 'Content-Type' 'text/plain charset=UTF-8'; - add_header 'Content-Length' 0; - return 200; # Well, maybe 204, no content. - } - add_header 'Access-Control-Allow-Origin' '*' always; - add_header 'Access-Control-Allow-Credentials' 'true' always; - add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, PATCH, DELETE, OPTIONS' always; - add_header 'Access-Control-Allow-Headers' 'Authorization,OnboardingSession,DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type' always; - } + listen 443 ssl; + listen [::]:443 ssl; + ssl_certificate /etc/ssl/star_smartcitizen_me.pem; + ssl_certificate_key /etc/ssl/star_smartcitizen_me.key; - client_max_body_size 4G; - keepalive_timeout 10; + rewrite ^/users/(.*)$ https://staging-api.smartcitizen.me/ui/users/$1 permanent; + rewrite ^/sessions/(.*)$ https://staging-api.smartcitizen.me/ui/sessions/$1 permanent; + rewrite ^/(.*)$ https://staging-api.smartcitizen.me/$1 permanent; } server { @@ -76,6 +59,13 @@ server { listen [::]:443 ssl; ssl_certificate /etc/ssl/star_smartcitizen_me.pem; ssl_certificate_key /etc/ssl/star_smartcitizen_me.key; + root /app/public; + + location ~* ^/(assets|favicon.ico) { + expires max; + add_header Cache-Control public; + break; + } try_files $uri/index.html $uri @app; diff --git a/spec/features/session_management_spec.rb b/spec/features/session_management_spec.rb new file mode 100644 index 00000000..2de4ba58 --- /dev/null +++ b/spec/features/session_management_spec.rb @@ -0,0 +1,122 @@ +require "rails_helper" + +feature "User logs in" do + + let(:password) { "password123" } + let(:user) { create(:user, password: password, password_confirmation: password) } + + scenario "user logs in with email" do + visit "/login" + fill_in "Username or Email", with: user.email + fill_in "Password", with: password + click_on "SIGN IN TO YOUR ACCOUNT" + expect(page).to have_current_path(ui_users_path) + expect(page).to have_content("You have been successfully logged in!") + end + + scenario "user logs in with username" do + visit "/login" + fill_in "Username or Email", with: user.username + fill_in "Password", with: password + click_on "SIGN IN TO YOUR ACCOUNT" + expect(page).to have_current_path(ui_users_path) + expect(page).to have_content("You have been successfully logged in!") + end + + scenario "user logs in with erroneous password" do + visit "/login" + fill_in "Username or Email", with: user.username + fill_in "Password", with: "notarealpassword" + click_on "SIGN IN TO YOUR ACCOUNT" + expect(page).to have_current_path(ui_sessions_path) + expect(page).to have_content("Email or password is invalid") + end + + scenario "user logs in with erroneous username" do + visit "/login" + fill_in "Username or Email", with: "notarealusername" + fill_in "Password", with: password + click_on "SIGN IN TO YOUR ACCOUNT" + expect(page).to have_current_path(ui_sessions_path) + expect(page).to have_content("Email or password is invalid") + end + + scenario "user logs out" do + visit "/login" + fill_in "Username or Email", with: user.email + fill_in "Password", with: password + click_on "SIGN IN TO YOUR ACCOUNT" + click_on "Log Out" + expect(page).to have_current_path(new_ui_session_path) + expect(page).to have_content("Logged out!") + end + + scenario "user resets email" do + visit "/login" + fill_in "Username or Email", with: user.email + click_on "RESET PASSWORD" + expect(page).to have_content("Please check your email to reset the password") + visit "/password_reset/#{user.reload.password_reset_token}" + fill_in "Password", with: "newpassword456" + fill_in "Confirm new password", with: "newpassword456" + click_on "Change my password" + expect(page).to have_content("Changed password for: #{user.username}") + fill_in "Username or Email", with: user.username + fill_in "Password", with: "newpassword456" + click_on "SIGN IN TO YOUR ACCOUNT" + expect(page).to have_current_path(ui_users_path) + expect(page).to have_content("You have been successfully logged in!") + end + + scenario "user resets email but gives incorrect password confirmation" do + visit "/login" + fill_in "Username or Email", with: user.email + click_on "RESET PASSWORD" + expect(page).to have_content("Please check your email to reset the password") + visit "/password_reset/#{user.reload.password_reset_token}" + fill_in "Password", with: "newpassword456" + fill_in "Confirm new password", with: "password456" + click_on "Change my password" + expect(page).to have_current_path(ui_change_password_path) + expect(page).to have_content("Your password doesn't match the confirmation") + end + + scenario "user attempts to reset password with an erroneous token" do + visit "/password_reset/notarealtoken" + fill_in "Password", with: "newpassword456" + fill_in "Confirm new password", with: "newpassword456" + click_on "Change my password" + expect(page).to have_current_path(ui_change_password_path) + expect(page).to have_content("Your reset code might be too old or have been used before.") + end + + scenario "discourse_login_flow" do + secret = DiscourseController::DISCOURSE_SSO_SECRET + nonce = "12345" + return_sso_url = "https://discourse.example.com" + unencrypted_payload = "nonce=#{nonce}&return_sso_url=#{return_sso_url}" + payload = Base64.encode64(unencrypted_payload) + signature = OpenSSL::HMAC.hexdigest("sha256", secret, payload) + visit "/discourse/sso?sso=#{CGI.escape(payload)}&sig=#{signature}" + expect(page).to have_current_path(new_ui_session_path + "?goto=%2Fdiscourse%2Fsso") + expect(page).to have_content("Please Log In before using SSO") + fill_in "Username or Email", with: user.email + fill_in "Password", with: password + begin + click_on "SIGN IN TO YOUR ACCOUNT" + # The next doesn't exist, we just want to check the URL is correct: + rescue ActionController::RoutingError + expect(page.current_url).to match(/^#{DiscourseController::DISCOURSE_ENDPOINT}/) + uri = URI.parse(page.current_url) + params = CGI.parse(uri.query) + check_signature = OpenSSL::HMAC.hexdigest("sha256", secret, params["sso"][0]) + expect(params["sig"][0]).to eq(check_signature) + payload = CGI.parse(Base64.decode64(CGI.unescape(params["sso"][0]))) + expect(payload["nonce"][0]).to eq(nonce) + expect(payload["username"][0]).to eq(user.email) + expect(payload["email"][0]).to eq(user.email) + expect(payload["external_id"][0]).to eq(user.id.to_s) + expect(payload["return_sso_url"][0]).to eq(return_sso_url) + end + end +end diff --git a/spec/policies/user_policy_spec.rb b/spec/policies/user_policy_spec.rb index 1d6d4de0..d542b2ab 100644 --- a/spec/policies/user_policy_spec.rb +++ b/spec/policies/user_policy_spec.rb @@ -12,7 +12,7 @@ it { is_expected.to_not permitz(:update) } it { is_expected.to_not permitz(:destroy) } it { is_expected.to permitz(:request_password_reset) } - it { is_expected.to_not permitz(:update_password) } + it { is_expected.to permitz(:update_password) } end context "for a user" do @@ -22,7 +22,7 @@ it { is_expected.to permitz(:update) } it { is_expected.to permitz(:destroy) } it { is_expected.to_not permitz(:request_password_reset) } - it { is_expected.to permitz(:update_password) } + it { is_expected.to_not permitz(:update_password) } end end diff --git a/spec/requests/v0/password_resets_spec.rb b/spec/requests/v0/password_resets_spec.rb index 1cfd239c..13c1b440 100644 --- a/spec/requests/v0/password_resets_spec.rb +++ b/spec/requests/v0/password_resets_spec.rb @@ -117,6 +117,7 @@ it "can reset password with valid token" do expect(user.authenticate('newpass')).to be_falsey j = api_put "password_resets/#{user.password_reset_token}", { password: 'newpass' } + p response expect(j["username"]).to eq(user.username) expect(response.status).to eq(200)