はじめに
本記事は、社内の古くなっていたシステムをリニューアルした際の手順やノウハウをまとめたものになります。
また、当記事は以前の投稿記事「Ruby on Rails6.0の環境構築から新規プロジェクト作成まで(メモ)」で作成した環境を前提に作成していますので、参考にされる方はご注意ください。
前提条件
- 開発環境はCloud9
- Linuxコマンドの使い方がわかる程度の力量
- Web開発は初学者クラスの力量(ruby on rails開発未経験/Progateのレッスンは修了済)
- HTML/CSSは難しい事はできないけど書いて読める程度の力量
- javascriptは難しい事はできないけど書いて読める程度の力量
参考記事
RubyのNet::SSHの使い方.リモートサーバー内でsuしたりrsyncするrubyスクリプトが作れるようになる【外部サイト】
Rails6プロジェクトの各種初期設定【外部サイト】
rails newするときによく使うオプションと、rails newした後によく行う設定
bootsnapについて調べてみた
Rails5.1ではAsset Pipeline捨てたほうがいいらしいので捨ててみた
application.html.erbのレイアウトの使い方と使わない方法【外部サイト】
yieldとcontent_forを使ってページ毎にタイトルを変更【外部サイト】
最新版で学ぶwebpack 4入門JavaScriptのモジュールバンドラ【外部サイト】
Rails 6+Webpacker開発環境をJS強者ががっつりセットアップしてみた(翻訳)【外部サイト】
アプリケーションの概要
今回作成したアプリケーションは、Linux系サーバーOS上にあるユーザーのパスワードをSSH等でログインしなくても、Webページ上で変更できるにするためのものです。
「どんな時に使うの?」という話ですが、自社運用のメールサーバーをユーザー自身で定期的にパスワードを変更させたい時に使う事を想定しています。
仕様的には下記のような感じにしました。
【ざっくり仕様】
- 入力された4つのパラメーター(ユーザーID、現行パスワード、新パスワード、新パスワード再確認)を元に対象となるサーバーへSSH接続してパスワード変更処理
- 正常終了された場合は、注意事項などが記載されたページを表示
- 異常終了された場合(パスワード間違いなど)は、再度元のページを表示して状況に応じて入力時の値を再設定
プロジェクト環境準備
今回はアプリケーション解説がメインのため、準備は簡単に流していきます。
まずは、プロジェクトに必要なディレクトリを作成しておきます。
$ mkdir -p PasswordChange/vendor/bundle/
$ tree PasswordChange
PasswordChange
└── vendor
└── bundle
2 directories, 0 files
次にRailsのインストールからbundleインストールまで実施します。
※手順の詳細は前回の記事を参照してください。
Ruby on Rails6.0の環境構築から新規プロジェクト作成まで(メモ)
尚、今回のアプリケーションでは各種Action(Mail関連、データベース関連等)を利用しないため、Offにしてプロジェクトを作成します。
$ bundle exec rails new . --skip-action-mailer --skip-action-mailbox --skip-action-text --skip-active-record --skip-active-storage --skip-action-cable --skip-test --skip-bootsnap --skip-turbolinks --skip-sprockets --skip-coffee --skip-bundle
各オプション | 説明 |
---|---|
--skip-action-mailer | action mailer のセットアップをスキップ |
--skip-action-mailbox | action mailbox のセットアップをスキップ |
--skip-action-text | action text のセットアップをスキップ |
--skip-active-record | action record のセットアップをスキップ |
--skip-active-storage | action storage のセットアップをスキップ |
--skip-action-cable | action cable のセットアップをスキップ |
--skip-test | Minitest のセットアップをスキップ |
--skip-bootsnap | bootsnap のセットアップをスキップ |
--skip-turbolinks | turbolinks のセットアップをスキップ |
--skip-sprockets | sprockets のセットアップをスキップ |
--skip-coffee | coffee のセットアップをスキップ |
--skip-bundle | bundle のセットアップをスキップ |
次に「webpacker」をインストールします
※オプションにそれらしいのがあったので最初はいけるかと思ったのですが、それだけでは不十分らしいので別途インストール
$ bundle exec rails webpacker:install
次に今回のアプリケーションで使用するgemを追加します。
今回追加するgemは「SSH接続用(net-ssh)」「通信確認用(net-ping)」「共通パラメーター設定用(settingslogic)」の3つになります。
※ちなみに、何故railsのインストール時に一緒に記述しないかというとGemfile上書き時に消えてしまったためです。
$ vim Genfile
【追記】
gem 'net-ping'
gem 'net-ssh'
gem 'settingslogic'
$ bundle install
次に今回作成予定の「controller」と「view」ファイルを作成します。
$ bundle exec rails g controller users_password top_form complete
create app/controllers/users_password_controller.rb
route get 'users_password/top_form'
get 'users_password/complete'
invoke erb
create app/views/users_password
create app/views/users_password/top_form.html.erb
create app/views/users_password/complete.html.erb
invoke helper
create app/helpers/users_password_helper.rb
invoke assets
invoke scss
create app/assets/stylesheets/users_password.scss
次に今回のアプリケーション用の「routes」設定します。
$ vim app/confing/routes.rb
【設定変更】
get 'complete' => 'users_password#complete'
get 'top' => 'users_password#top_form'
post 'top' => 'users_password#top'
get '/' => 'users_password#top_form'
「webpacker」関連の初期フォルダを作成します。
※フォルダ名はお好みでどうぞ
$ mkdir -p app/javascript/src app/javascript/stylesheets
「jquery」を「webpacker」にインストールします。
$ yarn add jquery
$ vim config/webpack/environment.js
【設定変更前】
const { environment } = require('@rails/webpacker')
module.exports = environment
【設定変更後】
const { environment } = require('@rails/webpacker')
const webpack = require('webpack')
environment.plugins.prepend('Provide',
new webpack.ProvidePlugin({
$: 'jquery/src/jquery',
jQuery: 'jquery/src/jquery'
})
)
module.exports = environment
$ vim app/javascript/packs/application.js
【追記】
require("jquery")
「webpacker」でデフォルトエントリーポイント以外で、各ビュー単位のエントリーポイントを利用できるように設定を一部修正します。
※この設定をしないとコンパイルはされますが、エントリーポイント(CSS)の呼び出し(stylesheet_pack_tag)が上手くいきませんでした。
理由は正直よくわかっていません(;^ω^)。
$ vim config/webpacker.yml
【設定変更前】
extract_css: false
【設定変更後】
extract_css: true
機能を実装
独自クラスの実装
まず、railsと直接関係ない「SSH接続パスワード変更」「共通パラメーター読込」の実装する準備をします。
$ mkdir -p app/lib
$ touch app/lib/settings.rb app/lib/ssh_interactive.rb
次に「SSH接続パスワード変更」の機能を追加します。
このクラスでは、デフォルト設定だと通常の「ID・パスワード」でSSH接続(※1)、オプションを付け足す事で「公開鍵認証」でSSH接続します。
パスワード変更部分は対話式で「passwd」コマンド実行となるため、環境によって出力メッセージが違う場合、変更(※2)が必要となります。
※1.実はcloud9環境で作成した関係で「公開鍵認証」はテストしましたが、「ID・パスワード」はテストできていなかったりします。
※2.「if data =~ /^(current) UNIX password/ then」等の部分が該当します。
require 'bundler/setup'
require 'rubygems'
require 'net/ssh'
require 'net/ping'
class SshInteractive
def initialize
# SSH接続先アドレス
@host = "127.0.0.1"
# SSH接続用オプション設定
@option = { :port => 22 }
end
def set_host(host)
@host = host
end
def set_port(port)
@option[:port] = port
end
def set_publickey_auth(keyfile, passphrase)
@option[:keys] = keyfile
@option[:passphrase] = passphrase
end
def password_change(user_id, pass_old, pass_new, pass_verify)
# パスワード追加
@option[:password] = pass_old unless @option.has_key?(:keys) && @option.has_key?(:passphrase)
# コマンド設定
cmd = "passwd"
# SSH接続
begin
return -10 unless port_scan? #通信接続エラー
Net::SSH.start(@host, user_id, @option) do |ssh|
channel = ssh.open_channel do |ch|
channel.request_pty do |ch, success| # ptyチェック
return -11 unless success
end
channel.exec cmd do |ch, success| # コマンド送信と対話入力
return -12 unless success
ch.on_data do |c, data| # メッセージ取り出し
if data =~ /^\(current\) UNIX password/ then
channel.send_data "#{pass_old}\n" #パスワードを送信する
elsif data =~ /^.+Authentication token manipulation error/ then
return -2 # パスワード間違い
elsif data =~ /^BAD PASSWORD: The password fails the dictionary check/ then
return -3 # 辞書攻撃チェック
elsif data =~ /^New password/ then
channel.send_data "#{pass_new}\n" #パスワードを送信する
elsif data =~ /^Retype new/ then
channel.send_data "#{pass_verify}\n" #パスワードを送信する
elsif data =~ /^.+updated successfully/ then
return 0
end
end
end
end
ssh.loop # SSHループ用
end
return -99 # 予想しない終了
rescue
return -1 # SSH接続エラー(ユーザーID間違い)
end
end
private
def port_scan?
ping_tcp = Net::Ping::TCP.new(@host, @option[:port])
return ping_tcp.ping?
end
end
次に「共通パラメーター読込」の機能を追加します。
このクラスでは、設定値やエラーメッセージ等を一元管理するために作成しています。
設定値は「config/application.yml」に記載します。
class Settings < Settingslogic
source "#{Rails.root}/config/application.yml"
namespace Rails.env
end
default: &default
company: XXXXX
system: XXXXXサーバー
password:
limit: 180日
alert: 30日
message:
normal: パスワード変更が完了しました
regular_access_err: パスワード変更処理を実施してください
password_check_err: 古いパスワードと新しいパスワードが同じです
password_verify_err: 新しいパスワードと新しいパスワード(確認)が一致しません
password_terms_err: 複雑性を満たすパスワードになっていません
password_lenght_err: パスワードの文字数が基準を満たしていません
password_matchid_err: 新しいパスワードにユーザーIDと同じ文字列が含まれています
authenticate_err: ユーザーID又はパスワードが間違っています
password_nomatch_err: パスワードが間違っています
dictionary_check_err: 辞書攻撃チェックに該当します
connection_err: サーバーへの接続に失敗しています【管理者へ問い合わせてください】
unknown_err: 予期しないエラーが発生しました【管理者へ問い合わせてください】
production:
<<: *default
ssh_params:
host: 000.000.000.000
port: 22
keys: /home/【ユーザー名】/.ssh/id_rsa
passphrase: test
development:
<<: *default
ssh_params:
host: 000.000.000.000
port: 22
keys: /home/【ユーザー名】/.ssh/id_rsa
passphrase: test
test:
<<: *default
ssh_params:
host: 000.000.000.000
port: 22
keys: /home/【ユーザー名】/.ssh/id_rsa
passphrase: test
Web関連の機能作成(Controller)
さて、ここから本題のセルフパスワード変更ページを作成していきます。
まずは、パスワード変更処理前に完了画面に行けないようにするための設定を追加します。
class ApplicationController < ActionController::Base
def authenticate_user
if session[:user_id] == nil
flash[:notice] = Settings.message.regular_access_err
redirect_to("/top")
end
end
end
次にパスワード変更処理の機能追加します。
パスワード変更フォームでは、最初にパスワード入力チェックを実施します。
その後、問題なければ先程作成した「SSH接続機能」を呼び出してパスワード変更処理を実施します。
特に問題なく変更できれば完了画面へ飛ばして終了です。
入力チェックエラーの場合は、フォーム画面に戻してエラーメッセージ「error_message」を表示します。
画面が切り替わったり、注視して欲しいメッセージを表示する場合は「flash」を使用しています。
require 'ssh_interactive'
require 'settings'
class UsersPasswordController < ApplicationController
before_action :authenticate_user, { only: [:complete]}
def complete
session[:user_id] = nil
end
def top_form
end
def top
# パラメーター格納
data = {
:user_id => params[:user_id],
:pass_old => params[:password_old],
:pass_new => params[:password_new],
:pass_verify => params[:password_verify]
}
# 旧パスワードと新パスワード比較
if data[:pass_old] == data[:pass_new]
data[:msg] = Settings.message.password_check_err
error_msg(data) and return
end
# 新パスワード比較
if data[:pass_new] != data[:pass_verify]
data[:msg] = Settings.message.password_verify_err
error_msg(data) and return
end
# 複雑性を満たすパスワード確認
# 半角英小文1文字以上、半角大文字1文字以上、半角数字1文字以上、半角記号1文字以上 ! # $ % $ * + - / = @ ?
if data[:pass_new] !~ /(?=.*?[a-z])(?=.*?[A-Z])(?=.*?\d)(?=.*?[!#$%&*+\-_\/=@\?])/
data[:msg] = Settings.message.password_terms_err
error_msg(data) and return
end
# 文字数制限確認(8桁以上20桁以下)
if data[:pass_new].length < 8 || data[:pass_new].length > 20
data[:msg] = Settings.message.password_lenght_err
error_msg(data) and return
end
# パスワード内にユーザーIDが含まれているか
if data[:pass_new] =~ /#{data[:user_id]}/
data[:msg] = Settings.message.password_matchid_err
error_msg(data) and return
end
# SSHパスワード変更処理
ssh = SshInteractive.new
ssh.set_host(Settings.ssh_params.host)
ssh.set_port(Settings.ssh_params.port)
ssh.set_publickey_auth(Settings.ssh_params.keys, Settings.ssh_params.passphrase)
result = ssh.password_change(data[:user_id],data[:pass_old],data[:pass_new],data[:pass_verify])
# 結果判定
case result
when 0 then
# パスワード変更確認
flash[:notice] = Settings.message.normal
session[:user_id] = data[:user_id]
redirect_to("/complete")
when -1 then
data[:msg] = Settings.message.authenticate_err
error_msg(data)
when -2 then
data[:msg] = Settings.message.password_nomatch_err
error_msg(data)
when -3 then
data[:msg] = Settings.message.dictionary_check_err
error_msg(data)
when -10,-11,-12 then
flash[:alert] = Settings.message.connection_err
render("users_password/top_form")
else
flash[:alert] = Settings.message.unknown_err
render("users_password/top_form")
end
end
private
def error_msg(**data)
# 入力状態に戻してページ再表示
@error_message = data[:msg]
@user_id = data[:user_id]
@password_old = data[:pass_old]
@password_new = data[:pass_new]
@password_verify = data[:pass_verify]
render("users_password/top_form")
end
end
Web関連の見た目部分作成
次に各ビューファイルを設定します。
<!DOCTYPE html>
<html>
<head>
<title><%= content_for?(:html_title) ? yield(:html_title) : "パスワード変更システム" %></title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= javascript_pack_tag 'application' %>
<%= stylesheet_pack_tag 'application' %>
<%= yield(:html_head) %>
</head>
<body>
<header>
<div class="header-log">
<h1><%= Settings.company %></h1>
</div>
</header>
<% if flash[:notice] %>
<div class="flash">
<%= flash[:notice] %>
</div>
<% end %>
<% if flash[:alert] %>
<div class="flash">
<%= flash[:alert] %>
</div>
<% end %>
<%= yield %>
</body>
</html>
<% content_for :html_head do %>
<%= stylesheet_pack_tag 'top_form' %>
<% end %>
<div class="main">
<div class="container">
<div class="heading">パスワード変更システム</div>
<div class="form">
<div class="form-body">
<% if @error_message %>
<div class="form-error">
<%= @error_message %>
</div>
<% end %>
<%= form_tag("/top") do %>
<p>ユーザーID</p>
<input id="user_id" name="user_id" value="<%= @user_id %>" required max=10>
<p>現行パスワード</p>
<input type="password" id="password_old" name="password_old" value="<%= @password_old %>" required min=8 maxlength=20>
<p>新しいパスワード</p>
<input type="password" id="password_new" name="password_new" value="<%= @password_new %>" required min=8 maxlength=20>
<p>新しいパスワード(確認)</p>
<input type="password" id="password_verify" name="password_verify" value="<%= @password_verify %>" required min=8 maxlength=20>
<input type="submit" value="変更">
<% end %>
</div>
</div>
</div>
<div class="container">
<div class="heading">システム説明</div>
<div class="box">
<div class="desc-body">
<h2>概要</h2>
<p>当システムでは、弊社で提供している<%= Settings.system %>のパスワード変更を実施できます。</p>
<h2>パスワードポリシー</h2>
<p>弊社提供の<%= Settings.system %>は下記ポリシーを適用しています。</p>
<p>様々な脅威から情報資産を守るためにも、ポリシーを遵守いただくようお願い致します。</p>
<ul>
<li>
有効期限<%= Settings.password.limit %>(期限切れ利用不可)
</li>
<li>
パスワード文字数は8~20文字以内
</li>
<li>
パスワード複雑性(下記を含む文字列)
<ul>
<li>
半角英小文字1文字以上(a-z)
</li>
<li>
半角英大文字1文字以上(A-Z)
</li>
<li>
半角数字1文字以上(0-9)
</li>
<li>
半角記号1文字以上<br/>利用可 ! # $ % & * + - _ / = @ ?
</li>
<li>
ユーザーIDと同じ文字列禁止<br/>ユーザーID:taro<br/>新しいパスワード:Ka@1<span class="font_red">taro</span>
</li>
</ul>
<li>
その他
<ul>
<li>
現行と類似したパスワードは使用しないでください。<br/>現行パスワード:Ka@isyai12<span class="font_red">3</span><br/>新しいパスワード:Ka@isya12<span class="font_red">4</span>
</li>
</ul>
</li>
</ul>
</div>
</div>
</div>
</div>
<% content_for :html_head do %>
<%= stylesheet_pack_tag 'complete' %>
<% end %>
<div class="main">
<div class="container">
<div class="heading">パスワード変更後の注意事項</div>
<div class="box">
<div class="body">
<ul>
<li>
<p>お手数をおかけいたしますが、クライアント(パソコン等)側で設定されています<span class="font_red">パスワードの変更</span>をお願い致します。</p>
</li>
<li>
<p>新しく設定したパスワードは忘れないように管理願います。</p>
<p>万が一、パスワードがわからなくなってしまった場合は、申し訳ありませんが<span class="font_red">パスワードの再発行手続きのためにXXXの申請</span>をお願い致します。</span></p>
</li>
<li>
<p>パスワードの有効期限は<%= Settings.password.limit %>です。</p>
<p>有効期限切れ後は、<span class="font_red">パスワード忘れと同じ扱い</span>となります。</p>
<p>尚、有効期限<%= Settings.password.alert %>前より毎日警告メールが発信されます。</p>
</li>
</ul>
</div>
</div>
</div>
</div>
次にCSS関連の設定を追加していきます。
まずは、cssファイルとエントリーポイント登録を実施します。
$ touch app/javascript/stylesheets/style.scss app/javascript/stylesheets/top_form.scss app/javascript/stylesheets/complete.scss
$ touch app/javascript/packs/top_form.js app/javascript/packs/complete.js
$ vim app/javascript/packs/application.js
【設定追加】
import '../stylesheets/style.scss'
$ vim app/javascript/packs/top_form.js
【設定追加】
import '../stylesheets/top_form.scss'
$ vim app/javascript/packs/complete.js
【設定追加】
import '../stylesheets/complete.scss'
次にCSSファイルの設定を追加していきます。
html {
font: 100%/1.5 'Avenir Next', 'Hiragino Sans', sans-serif;
line-height: 1.7;
letter-spacing: 1px;
}
ul,li {
list-style-type: none;
}
a {
text-decoration: none;
color: #2d3133;
font-size: 14px;
}
h1, h2, h3, h4, h5, h6, p {
margin: 0;
}
input {
background-color: transparent;
outline-width: 0;
}
form input[type="submit"] {
border: none;
cursor: pointer;
}
/* 共通レイアウト ================================ */
body {
color: #2d3133;
background-color: #3ecdc6;
margin: 0;
min-height: 1vh;
}
.main {
position: absolute;
top: 64px;
width: 100%;
height: auto;
min-height: 100%;
background-color: #f5f8fa;
}
.container {
max-width: 600px;
margin: 60px auto;
padding-left: 15px;
padding-right: 15px;
clear: both;
}
/* ヘッダー ================================ */
header {
height: 64px;
position: absolute;
z-index: 1;
width: 100%;
}
.header-logo {
float: left;
padding-left: 20px;
color: white;
font-size: 22px;
line-height: 64px;
}
/* フラッシュ ================================ */
.flash {
padding: 10px 0;
color: white;
background: rgb(251, 170, 88);
text-align: center;
position: absolute;
top: 64px;
z-index: 10;
width: 100%;
border-radius: 0 0 2px 2px;
font-size: 14px;
}
.font_red {
color: red;
}
.heading {
font-weight: 300;
margin: 60px 0 20px;
font-size: 48px;
color: #bcc8d4;
}
.form {
max-width: 600px;
margin: 0 auto;
background-color: white;
box-shadow: 0 2px 6px #c1ced7;
}
.form-body {
padding: 30px;
}
.form-error {
color: #ff4d75;
}
.form input {
width: 100%;
border: 1px solid #d8dadf;
padding: 10px;
color: #57575f;
font-size: 16px;
letter-spacing: 2px;
border-radius: 2px;
box-sizing: border-box;
}
.form textarea {
width: 100%;
min-height: 110px;
font-size: 16px;
letter-spacing: 2px;
}
.form input[type="submit"] {
background-color: #3ecdc6;
color: white;
cursor: pointer;
font-weight: 300;
width: 120px;
border-radius: 2px;
margin-top: 8px;
margin-bottom: 0;
float: right;
}
.form-body:after {
content: '';
display: table;
clear: both;
}
.box {
max-width: 600px;
margin: 0 auto;
background-color: white;
box-shadow: 0 2px 6px #c1ced7;
}
.desc-body {
padding: 30px;
}
.box li {
list-style-type: square;
display: list-item;
}
h2 {
position: relative;
margin: 1.5em 0em;
padding: 0.5em;
background: #a6d3c8;
color: white;
}
h2:before {
position: absolute;
margin-bottom: 1.0em;
content: '';
top: 100%;
left: 0;
border: none;
border-bottom: solid 15px transparent;
border-right: solid 20px rgb(149, 158, 155);
}
.heading {
font-weight: 300;
margin: 60px 0 20px;
font-size: 36px;
color: #bcc8d4;
}
.box {
max-width: 600px;
margin: 0 auto;
background-color: white;
box-shadow: 0 2px 6px #c1ced7;
}
.body {
padding: 30px;
}
.box li {
list-style-type: square;
display: list-item;
}
後はどうでもいいおしゃれポイントとして「flash」が表示された後、
5秒後にフェードアウトするjsを追加します。
$ touch app/javascript/src/flash_message.js
$ vim app/javascript/packs/application.js
【設定追加】
import '../src/flash_message.js'
(function() {
setTimeout("$('.flash').fadeOut('slow')", 5000)
})
これで一応作成完了です。
テスト機能とか本番環境用の設定とか色々ありますが、
それはまた別記事で紹介したいなと考えています。
アプリケーション作成後の所感
題材選びに、失敗した失敗した失敗した失敗した失敗した
失敗した失敗した失敗した失敗した失敗した
失敗した失敗した失敗した失敗した失敗した
失敗した失敗した失敗した失敗した失敗した
なんでオーソドックスなMVCモデルのアプリではなく、
DBを必要としない対話アプリを最初の題材に選んだのかというのが正直な感想。
※業務に関連する直近の課題をチョイスした結果なんですけどね。
おまけに、無駄なこだわりを色々入れてみた結果、
工数1日で作成完了していたアプリケーションが工数10日(ほぼ調査)まで膨れ上がってしまった。
これを見た初学者の皆様は題材選びと仕様設定には気を付けてください。
(おまけ)無駄にこだわって調べた点
- js、cssの管理をWebpackerで統一
- SSH接続部分は別クラスで実装したい
- 共通パラメーターの外部ファイル化
- css等のファイルをビュー単位で分割(applicationに書くのが嫌だった)
- 各ビューからhead内にCSSファイルを入れる
- プロジェクトで無駄に生成されるファイルを極力減らす
- 大してテストケースもないのに態々System Specを実装(別記事で紹介予定)